Skip to content

fix(client): centralize terminal detach so abandoned subscriptions are released#534

Merged
danshapiro merged 17 commits into
mainfrom
fix/terminal-attach-leak
Jul 25, 2026
Merged

fix(client): centralize terminal detach so abandoned subscriptions are released#534
danshapiro merged 17 commits into
mainfrom
fix/terminal-attach-leak

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

Problem

The server auto-kills idle DETACHED terminals (enforce_idle_kills, settings.safety.autoKillIdleMinutes, swept every 30s), but any attached subscriber exempts a terminal regardless of idle time. The client only sent terminal.detach on explicit tab close (TabBar); when a pane stopped referencing a terminal by any other path (pane re-point on session resume, dead-terminal recreate, pane close, unmount), the attach subscription leaked for the life of the WS connection. The server saw hasClients=true forever and never reaped.

Observed in production 2026-07-25: 10 orphaned CLI terminals idle 10.8–22.6h against a 3h threshold, all with hasClients=true; a server bounce then killed them, losing session state.

Fix (client-side only)

Replace the scattered per-site detach sends with one central store middleware (detach watcher) that diffs the pane layouts on every state change and sends terminal.detach for any terminalId that disappeared from all layouts.

Adds:

  • A helper walking all layouts to collect referenced terminal IDs
  • A "release marks" module so intentional kills don't trigger redundant detaches
  • A shared kill helper used by all kill sites
  • Removal of the three duplicated component-level detach sends
  • An attach gate so the client never attaches to a terminal that is not present in the layout (closes a close-during-create race found during the load-bearing assumption audit)

Unchanged: Explicit tab-close semantics (close = detach, shift+click = kill) and server idle-kill logic remain untouched.

Verification

✅ TDD throughout (subagent-driven, per-task spec + code-quality review)
✅ Full verification green: lint clean, typecheck clean, ~8,500 tests passing across both suites
✅ Independent whole-branch review and fresh-eyes delta review both PASSED with zero blocking issues
✅ Plan doc: docs/plans/2026-07-25-terminal-attach-leak.md


Generated with Amplifier

codex and others added 17 commits July 25, 2026 12:27
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…tion findings

Validated 12 load-bearing assumptions against server+client code (9 verified,
1 falsified, 2 accepted). Falsified: 'every subscription's id appears in
layouts' — the close-during-create race acquires a subscription invisible to
the layout diff. Added Task 11 (gate attachTerminal on layout membership),
renumbered verification to Task 12, and recorded validated server semantics
in Design Notes + Self-Review.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…ak plan

- Task 4: codex-identity-repair test now preloads pane content with a
  sessionRef equal to the action's expectedSessionRef (and mode 'codex');
  repairCodexIdentityMismatch guards on sessionRefsEqual and would no-op
  against the bare terminalContent() helper, making the asserted detach
  unreachable.
- Task 11: attach-gate snippet now uses the real in-scope bindings —
  attachTerminal's parameter is 'tid' (not 'terminalId') and the component's
  existing appStore (useAppStore(), :517) replaces the nonexistent 'store';
  import guidance updated accordingly.
- Corrected attachTerminal() anchors: declared at :2430 (the :2564 cites
  were its inner ws.send).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…eak plan

Fresh-eyes iteration 2 flagged four executable plan defects, all verified
against the actual test files and corrected:

- Task 6 Step 1: adding the middleware to the TabBar/tab-focus test stores
  turns three pre-existing exact-count tests RED (TabBar :698, :737
  toHaveBeenCalledTimes(2); e2e :332-335 strict filtered toEqual) because
  the component still double-sends. The step's 'Expected: PASS / double-send
  is not asserted anywhere yet' claim was false; it now declares the correct
  expected RED with per-test explanations and instructs not to 'fix' those
  tests. Steps 3 and 5 expectations updated to match.

- Task 6 Step 2: the reused fixtures use terminalId 'term-123' (:593 test)
  and 'term-456' (:632 shift test), not 'term-1'; the new tests' hardcoded
  assertions could never pass. Assertions and setup references corrected,
  pairing each new test with the matching existing fixture.

- Task 10: only the :88 replace-pane test asserts sends (:112); the :176 and
  :238 tests assert only tabs.tabs[0].terminalId, and their simulate blocks
  contain the load-bearing updateTab({terminalId: undefined}) dispatches
  (:200, :279) that the previous whole-block deletion example would have
  destroyed (the middleware never touches tabs state). Step 1 now deletes
  only the hand-rolled wsMocks.send lines, adds detach assertions to tests
  2 and 3, and moves the middleware concat after the RED run; Step 2's
  RED/GREEN expectations corrected.

- Task 8 Step 1: named the exact store helper to extend
  (createStoreWithTerminalPane :556, backing the :2286 test) among the
  file's six helpers, with an explicit warning that a file-wide or
  createTestStore addition breaks the :1275 test's strict
  expect(wsMocks.send).not.toHaveBeenCalled() (:1343).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… references

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…re and mark reopen kill

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…minalKill

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…hand-rolling it

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…e-race leak

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…etach

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@danshapiro
danshapiro merged commit c38c4fa into main Jul 25, 2026
2 checks passed
pull Bot pushed a commit to HinchK/freshell that referenced this pull request Jul 26, 2026
…ind plan

Validation pass (11 verified / 2 falsified; ledger in .the-usual-logs):
- A2 falsified: hydration queue has no reconnect pump; Task 5 now clears
  the stale slot, resets hydrationRegisteredRef, registers with
  queueIfStarted:true, and adds a real-queue unit test.
- A5 partially falsified: freshAgent.attach not unconditionally cheap;
  architecture wording corrected (attach pacing is load-bearing).
- A3 verified: dead-session attach contract proven; Task 6 contingency
  replaced with the INVALID_SESSION_ID facts.
- A4 edge: new Task 3 Step 4b hardens markSessionLost against missing
  session entries (empirically reproduced throw).
- A9/A10/A11/A12 refinements: create-serialization rationale, danshapiro#534 merge
  notes, e2e evidence-validity notes, requestId in create job keys.
pull Bot pushed a commit to HinchK/freshell that referenced this pull request Jul 26, 2026
…ttach gate

Cross-lane interaction with main: PR danshapiro#534 (landed after wave-A lanes
forked) added a layout-membership gate to TerminalView.attachTerminal —
collectAllTerminalIds(panes.layouts) must reference the terminal id or
the attach is suppressed. A4's hidden-rebind test store used empty
layouts, so the gate silently swallowed the background-rebind attach.

Production is unaffected: hidden panes are always present in
panes.layouts, and terminal.created writes the terminalId into the leaf
via updatePaneContent before the hydration queue grants the trigger.
The test now mirrors that reality by seeding a leaf layout for
tab-1/pane-1.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@danshapiro
danshapiro deleted the fix/terminal-attach-leak branch July 27, 2026 06:07
danshapiro added a commit that referenced this pull request Jul 27, 2026
…ype restore contract) (#538)

This is the analysis document underpinning the 2026-07-25 incident response — the D3
identity-capture gap, the per-pane-type restore contract, and the roadmap that PRs #534
(client attach-leak fix) and #535 (tab-diff verify NO CAPTURED IDENTITY verdict) reference.

Incorporates all round-2 council gates; pending final council confirmation.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-authored-by: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants