* 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:
1 parent
1c0c6ce5b4
commit
eadc52a096
3 files changed
+86
-18
No files matched your search
+52
-15
@@ -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
@@ -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
@@ -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` 查看会话序号。",
|
||||
|
||||
Reference in new issue
Block a user