Fix swallowed shellper reconnect error: recover connections in place instead of minting zombie terminals#1204
Fix swallowed shellper reconnect error: recover connections in place instead of minting zombie terminals#1204amrmelsayed wants to merge 30 commits into
Conversation
…rvable in ShellperClient
… write/resize in PtySession
…ients, wait for replay, fail dropped sends loudly
…writable guards
… recorded by cleanup
…onsult rebuttal record
Architect Integration Review — PIR #1198 (pr gate)Reviewed as the acting spawning architect for this builder. Recommendation: APPROVE for merge. No blocking issues. The Consult status (honest labeling)Single-pass PIR consult was effectively CMAP-2: I independently re-reviewed the un-re-reviewed fix (builder's explicit ask)The fd-leak guard in
Root-cause coverage — all four issue items addressed
Recovery loop safety (the highest-risk new code) — sound
Non-blocking scope notes (conscious-acceptance, not blockers)
Full suite: 3533 green. Clean implementation that fixes the root cause rather than papering over the symptom. Architect integration review |
…d declaration, cap replay at source
Architect Review — Addendum: incident-fix commit
|
…nup on dead declaration
Architect Review — delta
|
Architect Review — successor guard
|
…latter preserves a live shellper's socket
…(consult round 2)
Architect Review — round-2 deltas (
|
…s (instant-exit race)
Architect Review —
|
PIR Review: Shellper reconnect error swallowed — silent zombie terminals
Fixes #1198
Summary
A post-handshake socket or parser error on a shellper connection ran
cleanup()before the socket's'close'event fired, so the'close'emission (decided by reading_connected, already cleared) was swallowed — and with it every downstream recovery path. The terminal became a silent zombie: reportedrunning, all input dropped,"Message sent"logged for messages that went nowhere, until the next Tower restart. This PR makes the client record the owed'close'at teardown time (_closePending, captured insidecleanup()where the knowledge exists), and — because emitting'close'alone would have made a transient error permanently orphan a healthy shellper (the historical close path unlinks the live socket and deletes the SQLite row) — pairs it with in-place recovery: SessionManager reconnects to the still-alive shellper (bounded retry with PID/start-time/socket preflight), PtySession defers its destructive teardown behind a 15s grace timer that a successful re-attach cancels, and the Tower layer re-attaches the replacement client. Supporting fixes:'session-error'finally has a consumer (ERROR-logged), adoption awaits the REPLAY frame instead of racing it (blank-until-poked terminals), sends to a dead terminal return 503TERMINAL_NOT_WRITABLEand logMessage DROPPEDinstead of false success, andafx tower stoppolls until the process exits (SIGKILL after 8s) sostop && startserializes instead of overlapping adoption with teardown.Files Changed
packages/codev/src/terminal/shellper-client.ts(+31 / −8)packages/codev/src/terminal/session-manager.ts(+147 / −45)packages/codev/src/terminal/pty-session.ts(+65 / −11)packages/codev/src/terminal/pty-manager.ts(+15 / −0)packages/codev/src/agent-farm/servers/tower-server.ts(+27 / −0)packages/codev/src/agent-farm/servers/tower-terminals.ts(+7 / −2)packages/codev/src/agent-farm/servers/tower-routes.ts(+13 / −0)packages/codev/src/agent-farm/servers/send-buffer.ts(+14 / −0)packages/codev/src/agent-farm/commands/tower.ts(+35 / −0)packages/codev/src/terminal/__tests__/shellper-client.test.ts(+71 / −0)packages/codev/src/terminal/__tests__/session-manager.test.ts(+130 / −0)packages/codev/src/terminal/__tests__/pty-session-attach.test.ts(+34 / −1)packages/codev/src/terminal/__tests__/tower-shellper-integration.test.ts(+42 / −3)packages/codev/src/agent-farm/__tests__/send-buffer.test.ts(+41 / −1)packages/codev/src/agent-farm/__tests__/tower-routes.test.ts(+33 / −5)packages/codev/src/agent-farm/__tests__/tower-terminals.test.ts(+2 / −0)Commits
adb335eb[PIR Shellper reconnect error is swallowed: terminal becomes a silent zombie (no input/output, 'Message sent' logged for dropped frames) until next Tower restart #1198] Fix swallowed error-path close and make write/resize observable in ShellperClient00afce9f[PIR Shellper reconnect error is swallowed: terminal becomes a silent zombie (no input/output, 'Message sent' logged for dropped frames) until next Tower restart #1198] Reconnect in place on unexpected shellper socket close8e224038[PIR Shellper reconnect error is swallowed: terminal becomes a silent zombie (no input/output, 'Message sent' logged for dropped frames) until next Tower restart #1198] Grace-timer teardown on unexpected disconnect; observable write/resize in PtySessionf951a338[PIR Shellper reconnect error is swallowed: terminal becomes a silent zombie (no input/output, 'Message sent' logged for dropped frames) until next Tower restart #1198] Tower layer: log session errors, re-attach reconnected clients, wait for replay, fail dropped sends loudlyfb61c59d[PIR Shellper reconnect error is swallowed: terminal becomes a silent zombie (no input/output, 'Message sent' logged for dropped frames) until next Tower restart #1198] towerStop waits for process exit, escalating to SIGKILL after 8s58fc3526[PIR Shellper reconnect error is swallowed: terminal becomes a silent zombie (no input/output, 'Message sent' logged for dropped frames) until next Tower restart #1198] Tests: close emission, in-place reconnect, grace window, writable guards41398bff[PIR Shellper reconnect error is swallowed: terminal becomes a silent zombie (no input/output, 'Message sent' logged for dropped frames) until next Tower restart #1198] Collapse close-emission flags into a single _closePending recorded by cleanup54ef5bd4[PIR Shellper reconnect error is swallowed: terminal becomes a silent zombie (no input/output, 'Message sent' logged for dropped frames) until next Tower restart #1198] Extract towerStop wait timing into named constantsTest Results
pnpm build(workspace root): passpnpm test(packages/codev): pass — 3532 tests, 0 failures (13 new tests)'close'fires), intentional disconnect stays silent, write/resize delivery booleans, in-place reconnect success / dead-path / exhaustion (real Unix sockets against an in-process shellper), grace-window teardown and re-attach cancellation,writableguard, 503 send path, send-buffer hold-then-drop.tower-shellper-integration: "emits exit with code -1 on unexpected disconnect") asserted the old immediate-teardown behavior and was rewritten for the grace-window semantics.Architecture Updates
Routed to COLD (
codev/resources/arch.md, Shellper Process Architecture): a new "Connection-loss recovery (#1198)" subsection documenting the recovery pipeline (close-emission contract, in-place reconnect, grace-timer teardown, re-attach, write observability) and its invariant: never tear down a terminal's registry/DB state on a raw socket close without first checking whether the shellper process is alive. Nothing routed to HOT —arch-critical.mdis at its 10-fact cap and this is subsystem-internal behavior, not a cross-cutting decision-changing fact; no existing hot fact is weaker than it.Lessons Learned Updates
Routed to COLD (
codev/resources/lessons-learned.md, Debugging and Root Cause Analysis): two entries — (1) lifecycle emissions must be decided from state captured at the teardown transition, not re-read later, and un-swallowing a long-suppressed event requires auditing every consumer first (the naive fix would have converted zombies into permanently orphaned sessions); (2) success-shaped logging ("Message sent" with no delivery signal) turns an outage invisible — delivery paths need an observable failure contract at the API boundary, not just logs. Nothing routed to HOT (cap full; these are narrower than the current ten).Things to Look At During PR Review
killOrphanedShellpersthen killed both live shellpers and their Claude conversations. Fix, three layers: (1)FrameParsernow discards oversized frames incrementally ('frame-skipped'event) and keeps the stream alive — mandatory Tower-side because running shellpers are old binaries; (2)removeDeadSessionnever unlinks a live process's socket, so any future deterministic failure degrades to a recoverable orphan instead of feeding the kill sweeper; (3) new shellpers cap replay toREPLAY_PAYLOAD_MAX(8MB tail; client resolves skipped replay as empty and viewers repaint via the post-connect resize nudge). Regression tests at all three layers (client-level oversized-REPLAY survival test reproduces the incident shape byte-for-byte). Verified post-fix on the live fleet: all 20 surviving sessions show live-Tower fds and non-empty output buffers.RING_SEED_MAX_BYTES, logged when trimming); the shellper retains full history and TUIs repaint via the resize nudge. (2) Architect review finding (PID-reuse leak, Medium):removeDeadSessiongated socket unlink onisProcessAlivealone, so a reused PID would leak a dead shellper's socket/log. The unlink decision is now async best-effort (cleanupDeadSessionSocket) using the same alive + start-time guard as reconnection (isShellperProcessCurrent, shared withcanReachShellper);removeDeadSession's map mutation stays synchronous.status.yamlgate history, not retroactive frontmatter.attachShellper()opened the disk-log fd unconditionally; with re-attach now a routine recovery step, every reconnect leaked one append handle when disk logging is enabled. Fixed by guarding the open onlogFd === null(pty-session.ts;cleanupShellper()closes and nulls the fd, so post-teardown attaches still reopen). Regression test pins it:pty-session-attach.test.ts"does not reopen the disk log when a recovery re-attach arrives" (attach → re-attach → one open; detach → attach → second open). PIR's consultation is single-pass, so this fix was not independently re-reviewed — worth a human glance at the guard. The claude consultation verdict was APPROVE.session-manager.ts,recoverSession): the 3-attempt/3-round caps with the 30s stability reset, and thesession.client !== clientstale-wiring checks that prevent a replaced client's events from re-triggering recovery.pty-session.ts): the 15sSHELLPER_CLOSE_GRACE_MSteardown deferral is sized above SessionManager's worst-case recovery round (~4s);attachShellpercancels it. A genuinely dead terminal now reportsexitedup to 15s later than before (writes to it fail loudly during the window via thewritableguard).tower-server.ts): the ring buffer already holds session history; replaying would duplicate output. Known limitation: bytes the PTY emitted during the disconnection window (seconds) are not spliced in — a full-screen TUI repaints past this; a plain shell's scrollback genuinely misses those lines.findByShellperSessionId(pty-manager.ts): needed because the create flow keys SessionManager by a fresh UUID while adoption keys by terminal id.connect()has no handshake timeout — a shellper that accepts but never sends WELCOME would hang a recovery attempt (pre-existing exposure, shared with the adoption path; fails visible via the grace timer, not as a zombie); (c) possible enum consolidation of the client's connection-lifecycle state, best done together with (a).How to Test Locally
afx dev pir-1198, or for the definitive live test install this branch's build:cd .builders/pir-1198 && pnpm build && pnpm -w run local-installGET /api/terminals/:id/outputreturnstotal >= 1immediately (previously blank-until-poked); the previously zombified architects respond to typed input andafx send.afx tower stopreturns only after the process exits (lsof -i :4100is empty when it returns);stop && startshows no port-in-use retries.afx sendto it: the send errors withTERMINAL_NOT_WRITABLEand the Tower log showsMessage DROPPED, neverMessage sent.connection lost unexpectedlyfollowed byre-established/re-attached(recovery) or an ERROR line (genuine death) — never silence.