Repository navigation
fix(server): cancel an attach whose client leaves before the pty is ready - #3
Merged
Merged
Conversation
…eady The attach handler added the client to the attachment only after awaiting the terminal lookup. A detach or close that arrived during that await found nothing to remove, so the client was added afterwards anyway. The pty then outlived its last live client: a closed socket stayed in the set forever, and a detached client kept receiving the pane's bytes until it disconnected. The detach case is what switching panes quickly in the sidebar does (detach old, attach new). The pane is now recorded in `attached` before the await, so detach and close clear it, and the attach checks it on resume. A cancelled attach closes the attachment only when no other client still wants the pane. A joiner of the same creation may not have resumed yet, and closing eagerly would hand it a dead record. Tests: two contract tests (disconnect, detach) check that no `herdr terminal attach` process survives the last live client. With the fix reverted both fail 3/3; with the intent check removed both fail 3/3 because the remaining client never receives data. The observer-first test moves to its own pane so it no longer relies on the previous test having released the shared one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
releaseUnclaimed treats a pane in any client's `attached` set as a live claim. When a pty exits while its herdr pane lives on, closeAttachment deleted the record but left the pane in each member's set. Those members sit on the "terminal ended" screen, and their stale entries then vetoed closing a later attach on that pane that was cancelled mid-creation, so that pty kept running with no clients. closeAttachment now removes the pane from its members' sets. A probe (kill the attach under a watching client, then attach and detach the pane in one tick) leaves 0 attach processes with this change and 2 without it. No contract test pins it: nothing observable marks the moment a cancelled creation with no other joiner has finished. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Nov 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The
attachhandler added the client to the attachment and toclient.data.attachedonly afterawait ensureAttachment(...). A frame from the same socket that arrived during that await found nothing to clean up. Bun doesn't serialize an asyncmessagehandler per socket.closewalked an emptyattachedset, so the closed socket was added toattachment.clientsafterwards. It was never removed, and the pty outlived every live client.PaneTerminalsendsdetach(old)and thenattach(new)on every pane switch, so this is the more common trigger. The detach was a no-op, and the client was added to the old pane anyway. It kept receiving that pane's bytes, and the pty stayed alive, until the socket closed.Fix
client.data.attachedbefore the await.detachandcloseboth clear it, and the attach checks it when it resumes. If the attach fails, the entry is removed again.releaseUnclaimed. It closes the attachment only if it's still the current one, has no clients, and no other client has the pane inattached.Follow-up commit: stale claims after a pty exits
With the intent scan in place, an entry left behind in
attachedcounts as a live claim. When a pty exits while its herdr pane survives (e.g. herdr restarts),closeAttachmentdeleted the record but left the pane in each member'sattached. Those members are on the "terminal ended" screen. Their stale entries then blockedreleaseUnclaimedfrom closing a later cancelled attach on that pane, so the pty kept running with zero clients. It did recover on the member's next pane switch or disconnect.closeAttachmentnow removes the pane from its members'attached.No contract test pins this: when a cancelled creation has no other joiner, a client can't observe when it has finished. An ad-hoc probe checked it instead. It kills the attach under a watching client, then attaches and detaches the pane in one tick, and waits 3s. With the change 0 attach processes remain; without it, 2 remain.
Tests
WebSocket client leaving mid-attachhas one test for each trigger, and each runs on its own workspace. The oracle is the leaked resource itself: once the last live client is gone, noherdr terminal attach <terminal_id>process may remain (pgrep -f, bounded wait). Each test first asserts that the process count is above 0, so the pgrep pattern can't pass vacuously.describewith its own pane. It no longer relies on the previous roles test having released the sharedqaPaneId. The test body is unchanged.server/index.tsfix revertedattach … still running 5000ms after its last client left)releaseUnclaimedremovedstayer pty-data not received), so the eager-close trap is pinnedbun test server/api.contract.test.ts×4bun testbun run typecheck/bun run buildw9999:p9999×2 racing, then again)pane_not_foundframes, no hangherdr-web-ui-test*workspacesThe two new tests take about 1.5–2s each, because
herdr terminal attachtakes about 1.2s to exit after the sidecar kills it (measured separately).pushes pane-status …sometimes runs about 4.2s instead of 1.7s. That happens onmaintoo: its status retry loop occasionally takes an extra round. This PR doesn't change it.README: test count 63 → 65, and the coverage sentence now lists this case.
🤖 Generated with Claude Code