Skip to content

Commit 2a83596

Browse files
alari76claude
andauthored
fix: prevent session restart loops from process lifecycle races (#379)
Four fixes for race conditions that could cause sessions to repeatedly exit with SIGTERM (code 143) and burn through auto-restart attempts: - Broaden pkill orphan cleanup to match both --resume and --session-id flags, and replace unreliable \b with POSIX-safe (\s|$) - Switch setProvider to stopClaudeAndWait instead of fire-and-forget stopClaude with a 500ms delay - Clear pending _restartTimer in setModel/setProvider/setPermissionMode to prevent stale crash timers from spawning duplicate processes - Wrap _isStarting flag in try/finally in sendInput to prevent permanent session lockout if startClaude throws Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cf04937 commit 2a83596

File tree

2 files changed

+28
-10
lines changed

2 files changed

+28
-10
lines changed

server/claude-process.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,11 +213,16 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
213213
]
214214
// Kill any orphaned Claude process still holding this session ID's lock.
215215
// This can happen when the server restarts and old children survive (SIGKILL'd
216-
// or reparented to init). Without this, --resume fails with "already in use".
217-
// Uses the full binary path + exact session UUID to avoid matching unrelated processes.
216+
// or reparented to init), or when startClaude() replaces a running process
217+
// without waiting for the old one to exit. Without this, --resume fails
218+
// with "already in use".
219+
// Match both --resume and --session-id because the orphan may have been
220+
// started with either flag depending on whether it was a first start or
221+
// a resume. Uses the full binary path + exact session UUID to avoid
222+
// matching unrelated processes.
218223
if (this.resume) {
219224
try {
220-
const pattern = `${CLAUDE_BINARY} .*--resume ${this.sessionId}\\b`
225+
const pattern = `${CLAUDE_BINARY} .*(--resume|--session-id) ${this.sessionId}(\\s|$)`
221226
execFileSync('pkill', ['-f', pattern], { timeout: 2000, stdio: 'ignore' })
222227
} catch {
223228
// pkill exits 1 when no matching process is found — that's the happy path

server/session-manager.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1112,8 +1112,11 @@ export class SessionManager {
11121112
// Claude not running (e.g. after server restart or idle reap) — auto-start first.
11131113
// Claude CLI in -p mode waits for first input before emitting init,
11141114
// so we write directly to the stdin pipe buffer (no waiting for init).
1115-
this.startClaude(sessionId)
1116-
session._isStarting = false
1115+
try {
1116+
this.startClaude(sessionId)
1117+
} finally {
1118+
session._isStarting = false
1119+
}
11171120

11181121
// If we have a saved claudeSessionId, Claude CLI resumes with full
11191122
// conversation history from its own session storage — no need for our
@@ -1203,15 +1206,19 @@ export class SessionManager {
12031206
if (session.provider === provider) return true
12041207
session.provider = provider
12051208
session.claudeSessionId = null
1209+
// Clear any pending restart timer from a prior crash to prevent a stale
1210+
// timer from spawning a second process after we restart below.
1211+
if (session._restartTimer) { clearTimeout(session._restartTimer); session._restartTimer = undefined }
12061212
this.persistToDiskDebounced()
12071213
if (session.claudeProcess?.isAlive()) {
1208-
this.stopClaude(sessionId)
1209-
session._stoppedByUser = false
1210-
setTimeout(() => {
1211-
if (this.sessions.has(sessionId) && !session._stoppedByUser) {
1214+
// Use stopClaudeAndWait to ensure the old process fully exits before
1215+
// spawning a new one — avoids concurrent processes in the same worktree.
1216+
void this.stopClaudeAndWait(sessionId).then(() => {
1217+
if (this.sessions.has(sessionId)) {
1218+
session._stoppedByUser = false
12121219
this.startClaude(sessionId)
12131220
}
1214-
}, 500)
1221+
})
12151222
}
12161223
return true
12171224
}
@@ -1221,6 +1228,9 @@ export class SessionManager {
12211228
const session = this.sessions.get(sessionId)
12221229
if (!session) return false
12231230
session.model = model || undefined
1231+
// Clear any pending restart timer from a prior crash to prevent a stale
1232+
// timer from spawning a second process after we restart below.
1233+
if (session._restartTimer) { clearTimeout(session._restartTimer); session._restartTimer = undefined }
12241234
this.persistToDiskDebounced()
12251235
// Restart Claude with the new model if it's running.
12261236
// Use stopClaudeAndWait to ensure the old process fully exits before
@@ -1242,6 +1252,9 @@ export class SessionManager {
12421252
if (!session) return false
12431253
const previousMode = session.permissionMode
12441254
session.permissionMode = permissionMode
1255+
// Clear any pending restart timer from a prior crash to prevent a stale
1256+
// timer from spawning a second process after we restart below.
1257+
if (session._restartTimer) { clearTimeout(session._restartTimer); session._restartTimer = undefined }
12451258
this.persistToDiskDebounced()
12461259

12471260
// Audit log for dangerous mode changes

0 commit comments

Comments
 (0)