Fix pty.node SIGABRT crash on macOS shutdown (#1619)

* auto-claude: subtask-1-1 - Document root cause analysis of pty.node SIGABRT crash

Trace the code path from before-quit → destroyAllTerminals() → killPty(terminal)
(fire-and-forget, no wait) → app quits → environment cleanup →
ThreadSafeFunction callback fires → SIGABRT.

INVESTIGATION.md documents:
- Complete root cause chain with code evidence
- Race window timeline showing the ~100ms gap
- Why Electron's async before-quit handler doesn't actually block quit
- Proposed fix strategy (shutdown flag, wait-for-exit, preventDefault pattern)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-2-1 - Add isShuttingDown flag and guards to pty-manager

Add isShuttingDown flag with setShuttingDown()/getIsShuttingDown() exports
to pty-manager.ts. Guard onData handler to early-return during shutdown.
Guard onExit handler to skip win.webContents access and onExitCallback
during shutdown, while still resolving pendingExitPromises for
waitForPtyExit callers. Follows the isShuttingDown pattern from
pty-daemon-client.ts.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-1 - Refactor destroyAllTerminals() to wait for PTY exit

- Set shutdown flag via PtyManager.setShuttingDown(true) before killing terminals
- Use killPty(terminal, true) to wait for each PTY process to exit
- Wrap all kill promises in Promise.race with 3s global timeout to prevent hangs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Enhance before-quit handler with async PTY cleanup

Add re-entrancy guarded before-quit handler that uses event.preventDefault()
to pause quit while async PTY cleanup completes. This prevents pty.node
SIGABRT crashes caused by native ThreadSafeFunction callbacks firing after
JS environment teardown begins (GitHub #1469).

Changes:
- Add isQuitting module-level re-entrancy guard flag
- Use event.preventDefault() to pause quit for async cleanup
- Await terminalManager.killAll() (which now waits for PTY exit)
- Explicitly call ptyDaemonClient.shutdown() after terminal cleanup
- Wrap in try/catch with finally ensuring app.quit() always proceeds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-5-1 - Add defensive shutdown guards to pty-daemon.ts and pty-daemon-client.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-5-3 - Clean up INVESTIGATION.md artifacts, add GitHub #1469 references

Remove working INVESTIGATION.md files and add inline comments referencing
the shutdown guard pattern and GitHub issue #1469 for future maintainers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix shutdown race condition and improve error logging

Remove redundant before-quit handler in pty-daemon-client.ts that was
causing a race condition during app shutdown. The daemon is already
properly shut down in index.ts after terminal cleanup completes.

Add diagnostic logging to PTY cleanup error handler in terminal-lifecycle.ts
to improve observability during shutdown failures.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): sanitize PTY IDs in log statements to prevent log injection

Add sanitizeIdForLog helper to remove control characters and truncate
user-controlled PTY IDs before logging. This prevents log injection
attacks where malicious IDs could corrupt log output or spoof entries.

Addresses CodeQL alerts:
- Use of externally-controlled format string (HIGH)
- Log injection (MEDIUM)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): use CodeQL-recognized sanitization pattern for PTY logging

Refactor log statements to avoid template literal interpolation of user data:
- Use JSON.stringify in sanitizer (recognized by CodeQL as sanitizer)
- Pass sanitized IDs as separate console arguments instead of interpolating
- This separates the format string (literal) from user data

This pattern eliminates CodeQL alerts for:
- Use of externally-controlled format string (HIGH)
- Log injection (MEDIUM)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-30 11:38:16 +01:00
committed by GitHub
parent bde2ca4b2f
commit f355e09d78
6 changed files with 183 additions and 471 deletions
-421
View File
@@ -1,421 +0,0 @@
# Token Encryption Investigation
## Issue Summary
Auto-Claude users are experiencing API 401 errors ("Invalid bearer token") because the Python backend is passing encrypted tokens (with `enc:` prefix) directly to the Claude Agent SDK without decryption. Standalone Claude Code terminals work correctly because they decrypt these tokens before use.
**Key insight from user thehaffk:** "python cant unencrypt claude token and it launches session with CLAUDE_CODE_OAUTH_TOKEN=enc:djEwtxMGISt3tQ..."
## Token Storage Format
### Encrypted Token Format
Claude Code CLI stores OAuth tokens in an encrypted format with the prefix `enc:`:
```text
enc:djEwtxMGISt3tQ...
```
This format is used when tokens are stored in:
- **macOS**: Keychain (service: "Claude Code-credentials")
- **Linux**: Secret Service API (DBus, via secretstorage library)
- **Windows**: Credential Manager / .credentials.json files
### Decrypted Token Format
Valid Claude OAuth tokens have the format:
```text
sk-ant-oat01-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
```
## Current Token Flow (BROKEN)
1. **Token Storage**: Claude Code CLI stores encrypted token with `enc:` prefix in system keychain
2. **Token Retrieval**: `apps/backend/core/auth.py::get_auth_token()` retrieves token from:
- Environment variable `CLAUDE_CODE_OAUTH_TOKEN`
- OR system keychain via `get_token_from_keychain()`
3. **❌ NO DECRYPTION**: Token is returned as-is with `enc:` prefix intact
4. **SDK Initialization**: Encrypted token passed to Claude Agent SDK
5. **API Call Fails**: SDK sends encrypted token to API → 401 error
### Proof of Broken Flow
Test in `apps/backend`:
```python
import os
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:test123'
from core.auth import get_auth_token
token = get_auth_token()
print(f"Token: {token}") # Output: "enc:test123"
print(f"Encrypted: {token.startswith('enc:')}") # Output: True
```
## How Standalone Claude Code CLI Handles Tokens
### Current Understanding
1. **Token Detection**: CLI checks if token starts with `enc:` prefix
2. **Decryption**: If encrypted, CLI decrypts using platform-specific keyring access
3. **Authentication**: Decrypted `sk-ant-oat01-` token is used for API calls
### Missing Documentation
Web search for "Claude Code CLI encrypted token enc: prefix decryption" found:
- Token storage formats (JSON with accessToken, refreshToken, expiresAt)
- Security issues (tokens exposed in debug logs before v2.1.0)
- Keychain access patterns for macOS/Linux/Windows
**❌ NOT FOUND**: Specific documentation on how Claude Code CLI decrypts `enc:` tokens
Sources:
- [Claude Code CLI over SSH on macOS: Fixing Keychain Access](https://phoenixtrap.com/2025/10/26/claude-code-cli-over-ssh-on-macos-fixing-keychain-access/)
- [Identity and Access Management - Claude Code Docs](https://code.claude.com/docs/en/iam)
- [Claude Code sessions should be encrypted | yoav.blog](https://yoav.blog/2026/01/09/claude-code-sessions-should-be-encrypted/)
## Decryption Approach Options
### Option 1: Claude Agent SDK Built-in Decryption
**Status**: NEEDS VERIFICATION
The Claude Agent SDK (`claude-agent-sdk>=0.1.19`) may handle decryption internally if:
- Token is passed to SDK still encrypted
- SDK detects `enc:` prefix
- SDK has access to system keyring for decryption
**Action Required**: Check if SDK has decryption capabilities by examining:
- SDK source code or documentation
- Whether SDK expects encrypted vs decrypted tokens
- If SDK requires specific environment variables for decryption
### Option 2: Python Backend Decryption (Recommended)
**Approach**: Implement decryption in `apps/backend/core/auth.py` before passing to SDK
**Implementation Pattern**:
```python
def get_auth_token() -> str | None:
"""Get authentication token (decrypted if necessary)."""
token = _retrieve_token_from_sources() # From env or keychain
if token and token.startswith("enc:"):
# Decrypt the token
token = decrypt_token(token)
return token
def decrypt_token(encrypted_token: str) -> str:
"""
Decrypt Claude Code encrypted token.
Args:
encrypted_token: Token with 'enc:' prefix
Returns:
Decrypted token in format 'sk-ant-oat01-...'
"""
# Remove 'enc:' prefix
encrypted_data = encrypted_token[4:]
# TODO: Implement decryption logic
# Questions to answer:
# 1. What encryption algorithm does Claude Code use?
# 2. Where is the decryption key stored?
# 3. Is the decryption key platform-specific (per-user)?
# 4. Can we reuse Claude Code's decryption mechanism?
raise NotImplementedError("Token decryption not yet implemented")
```
### Option 3: Call Claude Code CLI for Decryption
**Approach**: Use the Claude Code CLI binary to decrypt tokens
```python
def decrypt_token(encrypted_token: str) -> str:
"""Decrypt token by invoking Claude Code CLI."""
# Find claude binary
claude_path = shutil.which("claude") or "~/.local/bin/claude"
# Use CLI command to get decrypted token
# (if such a command exists - needs research)
result = subprocess.run(
[claude_path, "auth", "decrypt", encrypted_token],
capture_output=True,
text=True
)
return result.stdout.strip()
```
**Issues**:
- Requires Claude Code CLI to be installed
- No documented CLI command for token decryption
- Adds external dependency
## Required Investigation Steps
### 1. Verify SDK Decryption Capabilities
**Task**: Check if `claude-agent-sdk` handles `enc:` tokens automatically
**Method**:
```bash
# In environment with SDK installed
python3 << 'EOF'
import os
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:...' # Real encrypted token
from claude_agent_sdk import Client
# Try creating client - does it decrypt internally?
client = Client()
# Check if authentication works
EOF
```
### 2. Reverse Engineer Claude Code CLI Decryption
**Task**: Understand how Claude CLI decrypts tokens
**Method**:
- Examine Claude CLI binary (if possible)
- Trace system calls when CLI runs (strace on Linux, dtruss on macOS)
- Check if CLI accesses specific keychain entries for decryption keys
- Look for encryption/decryption libraries used by CLI
### 3. Find Decryption Key Storage
**Task**: Locate where decryption keys are stored
**Hypothesis**: Decryption key stored in:
- macOS: Keychain (separate entry from encrypted token)
- Linux: Secret Service API
- Windows: Credential Manager
**Verification**:
```bash
# macOS: List all keychain entries
security find-generic-password -a "$(whoami)" | grep -i claude
# Linux: Use secretstorage to list all items
python3 -c "import secretstorage; ..."
```
## Recommended Decryption Approach for Python Backend
Based on investigation so far, the recommended approach is:
1. **Detect encrypted tokens**: Check for `enc:` prefix in `get_auth_token()`
2. **Decrypt before use**: Implement `decrypt_token()` function
3. **Platform-specific decryption**: Use appropriate keyring library:
- macOS: Use `subprocess` with `/usr/bin/security` to access decryption key
- Linux: Use `secretstorage` library to access Secret Service API
- Windows: Access Credential Manager or credentials.json
4. **Backward compatibility**: Support both encrypted and plaintext tokens
5. **Error handling**: Provide clear error messages if decryption fails
## Complete Token Flow Trace (Frontend → Backend)
### 1. Token Retrieval (Frontend)
**File**: `apps/frontend/src/main/services/profile-service.ts`
The frontend retrieves the OAuth token from the system keychain but **does not decrypt it**. When no API profile is active (OAuth mode), the frontend returns an empty environment object, which means it relies on:
- The token already being in the environment as `CLAUDE_CODE_OAUTH_TOKEN`
- OR the Python backend retrieving it from the keychain
**Key Code**:
```typescript
// Line 223: Returns empty object in OAuth mode, allowing
// CLAUDE_CODE_OAUTH_TOKEN to be used from system keychain
```
### 2. Environment Variable Passing (Frontend → PTY)
**File**: `apps/frontend/src/main/terminal/pty-manager.ts`
The PTY manager spawns the terminal shell with environment variables, including `CLAUDE_CODE_OAUTH_TOKEN`:
**Key Code** (Lines 149-152):
```typescript
// Remove ANTHROPIC_API_KEY to ensure Claude Code uses OAuth tokens
// (CLAUDE_CODE_OAUTH_TOKEN from profileEnv) instead of API keys
const { DEBUG: _DEBUG, ANTHROPIC_API_KEY: _ANTHROPIC_API_KEY, ...cleanEnv } = process.env;
```
**Important**: The frontend passes through whatever token value exists in the environment - it does NOT check for `enc:` prefix or decrypt it.
### 3. Token Retrieval (Backend)
**File**: `apps/backend/core/auth.py`
#### 3.1. get_auth_token()
This function retrieves the token from multiple sources:
```python
def get_auth_token() -> str | None:
# First check environment variables
for var in AUTH_TOKEN_ENV_VARS: # CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN
token = os.environ.get(var)
if token:
return token # ❌ Returns immediately without checking for enc: prefix
# Fallback to system credential store
return get_token_from_keychain() # ❌ Also returns without decryption
```
**Issue**: Returns token as-is with `enc:` prefix intact.
#### 3.2. require_auth_token()
This function calls `get_auth_token()` and raises an error if no token is found:
```python
def require_auth_token() -> str:
token = get_auth_token() # ❌ Gets encrypted token
if not token:
raise ValueError("No OAuth token found...")
return token # ❌ Returns encrypted token
```
**Issue**: No decryption step between retrieval and return.
#### 3.3. ensure_claude_code_oauth_token()
This function ensures the environment variable is set:
```python
def ensure_claude_code_oauth_token() -> None:
if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
return
token = get_auth_token() # ❌ Gets encrypted token
if token:
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token # ❌ Sets encrypted token
```
**Issue**: Propagates encrypted token to environment variable.
### 4. Token Usage in SDK Client Creation (Backend)
#### 4.1. Full Client Creation
**File**: `apps/backend/core/client.py` (see `create_client()` function)
```python
def create_client(...):
oauth_token = require_auth_token() # ❌ Gets encrypted token
# Ensure SDK can access it via its expected env var
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
```
**Issue**: Encrypted token is passed to the Claude Agent SDK, which expects a decrypted `sk-ant-oat01-` token.
#### 4.2. Simple Client Creation
**File**: `apps/backend/core/simple_client.py` (see `create_simple_client()` function)
```python
def create_simple_client(...):
# Get authentication
oauth_token = require_auth_token() # ❌ Gets encrypted token
import os
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
```
**Issue**: Same problem - encrypted token passed to SDK.
#### 4.3. Other Usages
**Files**:
- `apps/backend/core/workspace.py` (Line 1966) - AI merge operations
- `apps/backend/runners/insights_runner.py` - Insights analysis
- `apps/backend/runners/github/batch_issues.py` - GitHub batch operations
- `apps/backend/integrations/linear/updater.py` - Linear integration
- `apps/backend/commit_message.py` - Commit message generation
- `apps/backend/analysis/insight_extractor.py` - Code insights
- `apps/backend/merge/ai_resolver/claude_client.py` - Merge resolution
**All follow the same pattern**: Call `ensure_claude_code_oauth_token()` → encrypted token in environment → SDK receives encrypted token → API 401 error.
### 5. Where Decryption Should Be Inserted
Based on the flow analysis, decryption should be added at **the earliest point of token retrieval** to avoid duplicating decryption logic:
**RECOMMENDED INSERTION POINT**: `apps/backend/core/auth.py::get_auth_token()`
```python
def get_auth_token() -> str | None:
# First check environment variables
for var in AUTH_TOKEN_ENV_VARS:
token = os.environ.get(var)
if token:
# ✅ INSERT DECRYPTION HERE
if token.startswith("enc:"):
token = decrypt_token(token)
return token
# Fallback to system credential store
token = get_token_from_keychain()
# ✅ ALSO DECRYPT KEYCHAIN TOKENS
if token and token.startswith("enc:"):
token = decrypt_token(token)
return token
```
**Benefits of this approach**:
1. Single location for decryption logic
2. All downstream functions automatically get decrypted tokens
3. Backward compatible (plaintext tokens pass through unchanged)
4. Consistent behavior across all token sources (env vars and keychain)
**Alternative insertion points** (NOT recommended):
- `require_auth_token()` - Would need similar logic in `get_auth_token()` for non-required usage
- `create_client()` - Would need duplication in `create_simple_client()` and all other clients
- `ensure_claude_code_oauth_token()` - Would miss direct `get_auth_token()` calls
## Next Steps
1. ✅ Document current token flow and identify issue (THIS FILE - COMPLETED)
2. ✅ Trace token flow from frontend to backend (THIS FILE - COMPLETED)
3. ✅ Identify where decryption should be inserted (THIS FILE - COMPLETED)
4. ⏳ Verify if Claude Agent SDK handles decryption internally
5. ⏳ Reverse engineer or document Claude Code CLI decryption mechanism
6. ⏳ Implement `decrypt_token()` function in `apps/backend/core/auth.py`
7. ⏳ Add encryption detection and auto-decryption to `get_auth_token()`
8. ⏳ Test with real encrypted tokens on macOS and Linux
9. ⏳ Add comprehensive error handling for decryption failures
## Open Questions
1. **What encryption algorithm does Claude Code use for `enc:` tokens?**
- Possible: AES-256, ChaCha20, or similar
- Key derivation method?
2. **Where is the decryption key stored?**
- Same keychain entry as encrypted token?
- Separate keychain entry?
- Derived from system/user credentials?
3. **Does Claude Agent SDK expect encrypted or decrypted tokens?**
- If it expects decrypted: we must decrypt before passing
- If it handles encryption: we may be missing SDK configuration
4. **Is there a Claude Code CLI command to decrypt tokens?**
- `claude auth decrypt <token>`?
- `claude auth get-token`?
- No documented command found in research
5. **Can we reuse Claude Code's decryption mechanism?**
- Import decryption functions from CLI?
- Call CLI as subprocess?
- Implement decryption ourselves?
## References
- Issue: [GitHub #1223: API Error 401](https://github.com/AndyMik90/Auto-Claude/issues/1223)
- Current auth implementation: `apps/backend/core/auth.py`
- SDK client initialization: `apps/backend/core/client.py`
- Requirements: `apps/backend/requirements.txt` (includes `secretstorage>=3.3.3` for Linux)
+45 -12
View File
@@ -53,6 +53,7 @@ import { initSentryMain } from './sentry';
import { preWarmToolCache } from './cli-tool-manager';
import { initializeClaudeProfileManager, getClaudeProfileManager } from './claude-profile-manager';
import { isMacOS, isWindows } from './platform';
import { ptyDaemonClient } from './terminal/pty-daemon-client';
import type { AppSettings, AuthFailureInfo } from '../shared/types';
// ─────────────────────────────────────────────────────────────────────────────
@@ -140,6 +141,12 @@ let mainWindow: BrowserWindow | null = null;
let agentManager: AgentManager | null = null;
let terminalManager: TerminalManager | null = null;
// Re-entrancy guard for before-quit handler.
// The first before-quit call pauses quit for async cleanup, then calls app.quit() again.
// The second call sees isQuitting=true and allows quit to proceed immediately.
// Fixes: pty.node SIGABRT crash caused by environment teardown before PTY cleanup (GitHub #1469)
let isQuitting = false;
function createWindow(): void {
// Get the primary display's work area (accounts for taskbar, dock, etc.)
// Wrapped in try/catch to handle potential failures with fallback to safe defaults
@@ -494,24 +501,50 @@ app.on('window-all-closed', () => {
}
});
// Cleanup before quit
app.on('before-quit', async () => {
// Stop periodic update checks
// Cleanup before quit — uses event.preventDefault() to allow async PTY cleanup
// before the JS environment tears down. Without this, pty.node's native
// ThreadSafeFunction callbacks fire after teardown, causing SIGABRT (GitHub #1469).
app.on('before-quit', (event) => {
// Re-entrancy guard: the second app.quit() call (after cleanup) must pass through
if (isQuitting) {
return;
}
isQuitting = true;
// Pause quit to perform async cleanup
event.preventDefault();
// Stop synchronous services immediately
stopPeriodicUpdates();
// Stop usage monitor
const usageMonitor = getUsageMonitor();
usageMonitor.stop();
console.warn('[main] Usage monitor stopped');
// Kill all running agent processes
if (agentManager) {
await agentManager.killAll();
}
// Kill all terminal processes
if (terminalManager) {
await terminalManager.killAll();
}
// Perform async cleanup, then allow quit to proceed
(async () => {
try {
// Kill all running agent processes
if (agentManager) {
await agentManager.killAll();
}
// Kill all terminal processes — waits for PTY exit with bounded timeout
if (terminalManager) {
await terminalManager.killAll();
}
// Shut down PTY daemon client AFTER terminal cleanup completes,
// ensuring all kill commands reach PTY processes before the daemon disconnects
ptyDaemonClient.shutdown();
console.warn('[main] PTY daemon client shutdown complete');
} catch (error) {
console.error('[main] Error during pre-quit cleanup:', error);
} finally {
// Always allow quit to proceed, even if cleanup fails
app.quit();
}
})();
});
// Note: Uncaught exceptions and unhandled rejections are now
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'url';
import { spawn, ChildProcess } from 'child_process';
import { app } from 'electron';
import { isWindows, GRACEFUL_KILL_TIMEOUT_MS } from '../platform';
import { getIsShuttingDown } from './pty-manager';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
@@ -69,7 +70,7 @@ class PtyDaemonClient {
* Connect to daemon, spawning if necessary
*/
async connect(): Promise<void> {
if (this.isShuttingDown) {
if (this.shuttingDown) {
throw new Error('Client is shutting down');
}
@@ -174,7 +175,7 @@ class PtyDaemonClient {
this.socket.on('close', () => {
console.warn('[PtyDaemonClient] Disconnected from daemon');
this.socket = null;
if (!this.isShuttingDown) {
if (!this.shuttingDown) {
this.attemptReconnect();
}
});
@@ -222,7 +223,7 @@ class PtyDaemonClient {
* Attempt to reconnect with exponential backoff
*/
private attemptReconnect(): void {
if (this.isShuttingDown) return;
if (this.shuttingDown) return;
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[PtyDaemonClient] Max reconnect attempts reached');
@@ -287,10 +288,22 @@ class PtyDaemonClient {
// ===== Public API =====
/**
* Check if shutdown is in progress (either locally or globally via pty-manager)
*/
private get shuttingDown(): boolean {
return this.isShuttingDown || getIsShuttingDown();
}
/**
* Create a new PTY in the daemon
*/
async createPty(config: PtyConfig): Promise<string> {
// Guard against spawning new PTY processes after shutdown
if (this.shuttingDown) {
throw new Error('Cannot create PTY: client is shutting down');
}
const response = await this.request<{ type: 'created'; id: string }>({
type: 'create',
data: config,
@@ -302,14 +315,24 @@ class PtyDaemonClient {
* Write data to a PTY
*/
write(id: string, data: string): void {
this.send({ type: 'write', id, data });
if (this.shuttingDown) return;
try {
this.send({ type: 'write', id, data });
} catch {
// Socket may be closed during teardown
}
}
/**
* Resize a PTY
*/
resize(id: string, cols: number, rows: number): void {
this.send({ type: 'resize', id, data: { cols, rows } });
if (this.shuttingDown) return;
try {
this.send({ type: 'resize', id, data: { cols, rows } });
} catch {
// Socket may be closed during teardown
}
}
/**
@@ -441,8 +464,3 @@ class PtyDaemonClient {
// Singleton instance
export const ptyDaemonClient = new PtyDaemonClient();
// Cleanup on app quit
app.on('before-quit', () => {
ptyDaemonClient.shutdown();
});
+46 -13
View File
@@ -23,6 +23,18 @@ const MAX_BUFFER_SIZE = 100_000;
// Ring buffer to prevent memory growth
const RING_BUFFER_MAX_CHUNKS = 1000;
/**
* Sanitize an ID for safe logging to prevent log injection attacks.
* Uses JSON.stringify which CodeQL recognizes as a sanitizer, then
* removes the surrounding quotes for cleaner log output.
*/
function sanitizeIdForLog(id: string): string {
// JSON.stringify escapes control characters and is recognized by CodeQL
// as a sanitizer for log injection. We slice off the quotes for cleaner output.
const escaped = JSON.stringify(String(id).slice(0, 100));
return escaped.slice(1, -1);
}
interface ManagedPty {
id: string;
process: pty.IPty;
@@ -71,6 +83,7 @@ interface DaemonResponse {
class PtyDaemon {
private ptys = new Map<string, ManagedPty>();
private server: net.Server | null = null;
private isShuttingDown = false;
constructor() {
console.error('[PTY Daemon] Starting...');
@@ -236,6 +249,11 @@ class PtyDaemon {
* Create a new PTY
*/
private createPty(config: PtyConfig): string {
// Guard against spawning new PTY processes after shutdown has begun
if (this.isShuttingDown) {
throw new Error('Cannot create PTY: daemon is shutting down');
}
const id = `pty-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
@@ -290,7 +308,7 @@ class PtyDaemon {
});
ptyProcess.onExit(({ exitCode, signal }) => {
console.error(`[PTY Daemon] PTY ${id} exited: code=${exitCode}, signal=${signal}`);
console.error('[PTY Daemon] PTY exited:', { id: sanitizeIdForLog(id), exitCode, signal });
managed.isDead = true;
// Notify all subscribers
@@ -302,7 +320,7 @@ class PtyDaemon {
});
this.ptys.set(id, managed);
console.error(`[PTY Daemon] Created PTY ${id} (${config.shell})`);
console.error('[PTY Daemon] Created PTY:', { id: sanitizeIdForLog(id), shell: config.shell });
return id;
} catch (error) {
@@ -322,7 +340,13 @@ class PtyDaemon {
if (managed.isDead) {
throw new Error(`PTY ${id} is dead`);
}
managed.process.write(data);
try {
managed.process.write(data);
} catch (error) {
// PTY process may have been destroyed during teardown
console.error('[PTY Daemon] Error writing to PTY:', sanitizeIdForLog(id), error);
managed.isDead = true;
}
}
/**
@@ -334,12 +358,18 @@ class PtyDaemon {
throw new Error(`PTY ${id} not found`);
}
if (managed.isDead) {
console.warn(`[PTY Daemon] Cannot resize dead PTY ${id}`);
console.warn('[PTY Daemon] Cannot resize dead PTY:', sanitizeIdForLog(id));
return;
}
managed.process.resize(cols, rows);
managed.config.cols = cols;
managed.config.rows = rows;
try {
managed.process.resize(cols, rows);
managed.config.cols = cols;
managed.config.rows = rows;
} catch (error) {
// PTY process may have been destroyed during teardown
console.error('[PTY Daemon] Error resizing PTY:', sanitizeIdForLog(id), error);
managed.isDead = true;
}
}
/**
@@ -348,7 +378,7 @@ class PtyDaemon {
private killPty(id: string): void {
const managed = this.ptys.get(id);
if (!managed) {
console.warn(`[PTY Daemon] PTY ${id} not found for kill`);
console.warn('[PTY Daemon] PTY not found for kill:', sanitizeIdForLog(id));
return;
}
@@ -356,12 +386,12 @@ class PtyDaemon {
try {
managed.process.kill();
} catch (error) {
console.error(`[PTY Daemon] Error killing PTY ${id}:`, error);
console.error('[PTY Daemon] Error killing PTY:', sanitizeIdForLog(id), error);
}
}
this.ptys.delete(id);
console.error(`[PTY Daemon] Removed PTY ${id}`);
console.error('[PTY Daemon] Removed PTY:', sanitizeIdForLog(id));
}
/**
@@ -394,7 +424,7 @@ class PtyDaemon {
throw new Error(`PTY ${id} not found`);
}
managed.clients.add(socket);
console.error(`[PTY Daemon] Client subscribed to PTY ${id}`);
console.error('[PTY Daemon] Client subscribed to PTY:', sanitizeIdForLog(id));
}
/**
@@ -404,7 +434,7 @@ class PtyDaemon {
const managed = this.ptys.get(id);
if (managed) {
managed.clients.delete(socket);
console.error(`[PTY Daemon] Client unsubscribed from PTY ${id}`);
console.error('[PTY Daemon] Client unsubscribed from PTY:', sanitizeIdForLog(id));
}
}
@@ -448,13 +478,16 @@ class PtyDaemon {
const shutdown = (signal: string) => {
console.error(`[PTY Daemon] Received ${signal}, shutting down...`);
// Set shutdown flag to prevent new PTY creation and guard operations
this.isShuttingDown = true;
// Kill all PTYs
this.ptys.forEach((managed) => {
if (!managed.isDead) {
try {
managed.process.kill();
} catch (error) {
console.error(`[PTY Daemon] Error killing PTY ${managed.id}:`, error);
console.error('[PTY Daemon] Error killing PTY:', sanitizeIdForLog(managed.id), error);
}
}
});
+36 -1
View File
@@ -16,6 +16,32 @@ import type { SupportedTerminal } from '../../shared/types/settings';
// Windows shell paths are now imported from the platform module via getWindowsShellPaths()
/**
* Shutdown flag to prevent PTY handlers from accessing destroyed resources
* (e.g., BrowserWindow.webContents) during app shutdown.
* Follows the same pattern as isShuttingDown in pty-daemon-client.ts.
*
* Part of the shutdown guard pattern for GitHub issue #1469: without this flag,
* PTY onData/onExit callbacks can fire after BrowserWindow is destroyed,
* causing pty.node's native ThreadSafeFunction to SIGABRT.
*/
let isShuttingDown = false;
/**
* Set the shutting down flag. Call this during app quit/before-quit
* to prevent PTY handlers from accessing destroyed resources.
*/
export function setShuttingDown(value: boolean): void {
isShuttingDown = value;
}
/**
* Check if the PTY manager is in shutting down state.
*/
export function getIsShuttingDown(): boolean {
return isShuttingDown;
}
/**
* Result of spawning a PTY process
*/
@@ -184,6 +210,10 @@ export function setupPtyHandlers(
// Handle data from terminal
ptyProcess.onData((data) => {
// Shutdown guard (GitHub #1469): skip processing to avoid accessing
// destroyed BrowserWindow.webContents, which triggers pty.node SIGABRT
if (isShuttingDown) return;
// Append to output buffer (limit to 100KB)
terminal.outputBuffer = (terminal.outputBuffer + data).slice(-100000);
@@ -201,7 +231,8 @@ export function setupPtyHandlers(
ptyProcess.onExit(({ exitCode }) => {
debugLog('[PtyManager] Terminal exited:', id, 'code:', exitCode);
// Resolve any pending exit promise FIRST (before other cleanup)
// Always resolve pending exit promises, even during shutdown
// (needed for waitForPtyExit callers to complete)
const pendingExit = pendingExitPromises.get(id);
if (pendingExit) {
clearTimeout(pendingExit.timeoutId);
@@ -209,6 +240,10 @@ export function setupPtyHandlers(
pendingExit.resolve();
}
// Shutdown guard (GitHub #1469): skip accessing win.webContents and callbacks
// to avoid pty.node SIGABRT from destroyed BrowserWindow resources
if (isShuttingDown) return;
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_EXIT, id, exitCode);
@@ -294,12 +294,27 @@ export async function destroyTerminal(
}
/**
* Kill all terminal processes
* Global timeout for destroyAllTerminals to prevent shutdown from hanging (ms).
*/
const DESTROY_ALL_TIMEOUT = 3000;
/**
* Kill all terminal processes.
* Sets the shutdown flag first to prevent PTY handlers from accessing destroyed
* resources, then waits for all PTY processes to exit (with a global timeout).
*
* This is the core fix for GitHub issue #1469: by setting the shutdown flag and
* awaiting PTY exit before returning, we ensure pty.node's native callbacks
* don't fire after the JS environment tears down (which causes SIGABRT).
*/
export async function destroyAllTerminals(
terminals: Map<string, TerminalProcess>,
saveTimer: NodeJS.Timeout | null
): Promise<NodeJS.Timeout | null> {
// Set shutdown flag first — prevents PTY onData/onExit from accessing
// destroyed BrowserWindow.webContents (GitHub #1469 shutdown guard pattern)
PtyManager.setShuttingDown(true);
await SessionHandler.persistAllSessionsAsync(terminals);
if (saveTimer) {
@@ -307,25 +322,24 @@ export async function destroyAllTerminals(
saveTimer = null;
}
const promises: Promise<void>[] = [];
// Kill all terminals and wait for PTY exit to avoid pty.node SIGABRT on shutdown (GitHub #1469)
const killPromises: Promise<void>[] = [];
terminals.forEach((terminal) => {
promises.push(
new Promise((resolve) => {
try {
// Note: We intentionally don't wait for PTY exit here (unlike destroyTerminal)
// because this function is only called during app shutdown when no terminals
// will be recreated. Waiting would only delay shutdown unnecessarily.
PtyManager.killPty(terminal);
} catch {
// Ignore errors during cleanup
}
resolve();
killPromises.push(
PtyManager.killPty(terminal, true).catch((error) => {
console.warn('[TerminalLifecycle] Error during PTY cleanup:', error);
})
);
});
await Promise.all(promises);
// Wait for all PTY processes to exit, but cap with a global timeout
// so shutdown never hangs indefinitely
await Promise.race([
Promise.all(killPromises),
new Promise<void>((resolve) => setTimeout(resolve, DESTROY_ALL_TIMEOUT))
]);
terminals.clear();
return saveTimer;