fix: graceful session close — stdin EOF before signals (#509) (#510)

* fix: graceful session shutdown — close stdin before signals

The old Close() called context.Cancel() which sends SIGKILL immediately
via Go's exec.CommandContext, giving Claude Code zero time to run Stop
hooks. This broke claude-mem session summaries (120s timeout) and any
other cleanup that depends on a clean exit.

New three-phase shutdown:
1. Close stdin (EOF) — Claude Code exits cleanly, running Stop hooks
2. SIGTERM after 30s grace period if still alive
3. SIGKILL after 5s more as last resort

Also increases engine-level close timeout from 10s to 45s to accommodate
the longer graceful window.

Tested: all agent/claudecode and core tests pass. Verified Claude Code
exits cleanly on stdin EOF (exit code 0).

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

* fix: 120s graceful close + hold message during session transition

Increases the default graceful stop timeout from 30s to 120s to match
claude-mem's Stop hook timeout. The wait ends as soon as the process
exits — typical shutdowns take seconds, not the full 120s.

When a user messages during an idle-reset that triggers a graceful
close, they now see "Wrapping up your previous session (usually a few
seconds, up to 2 minutes). Your new session will start automatically."
instead of a silent delay. The hold message is sent before the blocking
close, and the new session starts immediately when the old one finishes.

Engine-level close timeout raised to 130s (120s grace + 5s SIGTERM +
5s buffer).

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Leigh StillardandClaude Opus 4.6 authored and GitHub committed 2026-04-09 14:31:01 +08:00
1 parent 1c0c6ce5b4
commit eadc52a096
3 files changed
+86 -18

No files matched your search

+52 -15
View File
@@ -16,6 +16,7 @@ import (
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/chenhg5/cc-connect/core"
@@ -41,6 +42,13 @@ type claudeSession struct {
cancel context.CancelFunc
done chan struct{}
alive atomic.Bool
// gracefulStopTimeout is how long Close() waits for a clean exit
// (stdin close → Stop hooks → process exit) before escalating to
// SIGTERM and then SIGKILL. Default: 120s to match claude-mem's
// Stop hook timeout. The wait ends as soon as the process exits,
// so typical shutdowns take seconds, not the full timeout.
gracefulStopTimeout time.Duration
}
func newClaudeSession(ctx context.Context, workDir, model, sessionID, mode string, allowedTools, disallowedTools []string, extraEnv []string, platformPrompt string, disableVerbose bool, maxContextTokens int) (*claudeSession, error) {
@@ -125,13 +133,14 @@ func newClaudeSession(ctx context.Context, workDir, model, sessionID, mode strin
}
cs := &claudeSession{
cmd: cmd,
stdin: stdin,
events: make(chan core.Event, 64),
workDir: workDir,
ctx: sessionCtx,
cancel: cancel,
done: make(chan struct{}),
cmd: cmd,
stdin: stdin,
events: make(chan core.Event, 64),
workDir: workDir,
ctx: sessionCtx,
cancel: cancel,
done: make(chan struct{}),
gracefulStopTimeout: 120 * time.Second,
}
cs.setPermissionMode(mode)
cs.sessionID.Store(sessionID)
@@ -568,19 +577,47 @@ func (cs *claudeSession) Alive() bool {
}
func (cs *claudeSession) Close() error {
cs.cancel()
// Phase 1: Close stdin to signal EOF. Claude Code exits cleanly on
// stdin close, running Stop hooks (e.g. claude-mem session summary).
cs.stdinMu.Lock()
_ = cs.stdin.Close()
cs.stdinMu.Unlock()
graceful := cs.gracefulStopTimeout
if graceful <= 0 {
graceful = 8 * time.Second // legacy fallback
}
select {
case <-cs.done:
slog.Info("claudeSession: exited cleanly after stdin close")
return nil
case <-time.After(8 * time.Second):
slog.Warn("claudeSession: graceful close timed out, killing process")
if cs.cmd != nil && cs.cmd.Process != nil {
_ = cs.cmd.Process.Kill()
}
<-cs.done
return nil
case <-time.After(graceful):
slog.Warn("claudeSession: graceful stop timed out, sending SIGTERM",
"timeout", graceful)
}
// Phase 2: SIGTERM — gives the process a second chance to run
// cleanup handlers that respond to signals but not stdin EOF.
if cs.cmd != nil && cs.cmd.Process != nil {
_ = cs.cmd.Process.Signal(syscall.SIGTERM)
}
select {
case <-cs.done:
slog.Info("claudeSession: exited after SIGTERM")
return nil
case <-time.After(5 * time.Second):
slog.Warn("claudeSession: SIGTERM timed out, sending SIGKILL")
}
// Phase 3: SIGKILL — last resort.
cs.cancel()
if cs.cmd != nil && cs.cmd.Process != nil {
_ = cs.cmd.Process.Kill()
}
<-cs.done
return nil
}
// filterEnv returns a copy of env with entries matching the given key removed.
+25 -2
View File
@@ -1500,6 +1500,20 @@ func (e *Engine) maybeAutoResetSessionOnIdle(p Platform, msg *Message, sessions
"threshold", e.resetOnIdle,
)
// Check if the old session has an agent process that needs graceful
// shutdown. If so, tell the user we're wrapping up before blocking.
e.interactiveMu.Lock()
state, hasState := e.interactiveStates[interactiveKey]
hasAgent := hasState && state != nil && state.agentSession != nil && state.agentSession.Alive()
e.interactiveMu.Unlock()
if hasAgent {
// Notify the user before the potentially long close. The close
// returns as soon as the process exits (usually seconds), but
// Stop hooks can take up to 120s.
e.reply(p, msg.ReplyCtx, e.i18n.T(MsgSessionClosingGraceful))
}
e.cleanupInteractiveState(interactiveKey)
session.UnlockWithoutUpdate()
@@ -2188,6 +2202,14 @@ func (e *Engine) closeAgentSessionWithTimeout(sessionKey string, agentSession Ag
return
}
// Allow enough time for the agent's own graceful shutdown sequence:
// stdin close → Stop hooks (claude-mem summary etc.) → SIGTERM → SIGKILL.
// Claude Code's Stop hooks can take up to 120s (claude-mem uses a
// sonnet summarizer). The 130s budget covers the default 120s graceful
// phase + 5s SIGTERM + 5s buffer. The wait ends early if the process
// exits sooner — this is the ceiling, not the typical duration.
const closeTimeout = 130 * time.Second
slog.Debug("cleanupInteractiveState: closing agent session", "session", sessionKey)
closeStart := time.Now()
@@ -2202,8 +2224,9 @@ func (e *Engine) closeAgentSessionWithTimeout(sessionKey string, agentSession Ag
if elapsed := time.Since(closeStart); elapsed >= slowAgentClose {
slog.Warn("slow agent session close", "elapsed", elapsed, "session", sessionKey)
}
case <-time.After(10 * time.Second):
slog.Error("agent session close timed out (10s), abandoning", "session", sessionKey)
case <-time.After(closeTimeout):
slog.Error("agent session close timed out, abandoning",
"timeout", closeTimeout, "session", sessionKey)
}
}
+9 -1
View File
@@ -394,7 +394,8 @@ const (
MsgNewSessionCreated MsgKey = "new_session_created"
MsgNewSessionCreatedName MsgKey = "new_session_created_name"
MsgSessionAutoResetIdle MsgKey = "session_auto_reset_idle"
MsgSessionAutoResetIdle MsgKey = "session_auto_reset_idle"
MsgSessionClosingGraceful MsgKey = "session_closing_graceful"
MsgDeleteUsage MsgKey = "delete_usage"
MsgDeleteSuccess MsgKey = "delete_success"
@@ -2708,6 +2709,13 @@ var messages = map[MsgKey]map[Language]string{
LangJapanese: "⏰ %d 分以上操作がなかったため、新しいセッションに自動切り替えました。",
LangSpanish: "⏰ La sesión se reinició automáticamente tras %d minuto(s) de inactividad.",
},
MsgSessionClosingGraceful: {
LangEnglish: "⏳ Wrapping up your previous session (usually a few seconds, up to 2 minutes). Your new session will start automatically.",
LangChinese: "⏳ 正在结束上一个会话(通常几秒钟,最多2分钟)。新会话将自动启动。",
LangTraditionalChinese: "⏳ 正在結束上一個會話(通常幾秒鐘,最多2分鐘)。新會話將自動啟動。",
LangJapanese: "⏳ 前のセッションを終了中です(通常は数秒、最大2分)。新しいセッションは自動的に開始されます。",
LangSpanish: "⏳ Cerrando la sesión anterior (normalmente unos segundos, hasta 2 minutos). La nueva sesión se iniciará automáticamente.",
},
MsgDeleteUsage: {
LangEnglish: "Usage: `/delete <number>` or `/delete 1,2,3` or `/delete 3-7` or `/delete 1,3-5,8`.\nUse `/list` to see session numbers.",
LangChinese: "用法:`/delete <序号>`,或 `/delete 1,2,3`,或 `/delete 3-7`,或 `/delete 1,3-5,8`。\n使用 `/list` 查看会话序号。",