Skip to content

fix(relay): forget a session's reported state when its process goes - #803

Merged
omartelo merged 1 commit into
mainfrom
fix/relay-forget-on-respawn
Oct 17, 2026
Merged

omartelo merged 1 commit into
mainfrom
fix/relay-forget-on-respawn

Conversation

@omartelo

Copy link
Copy Markdown
Collaborator

Why

The relay keeps two in-memory maps per session: the turn state that deliveries read, and the reported state that list_sessions publishes. It dropped a session's entry from both only on idle, which is Claude Code's SessionEnd. A process that is killed, crashes, or is closed mid-turn never sends it.

The terminal side already forgets the turn on reap and on close (forgetTurn), so the two disagreed. When the same id was respawned:

  • list_sessions kept listing it as busy;
  • stampDelivery treated it as mid-turn (skipTurns = 1, no receipt check);
  • held nudges kept waiting;
  • an abort did not help, because noteInterrupt found no open turn on the terminal side and never told the relay.

Found while investigating sessions that looked stuck as busy after the backend was OOM-killed. That incident turned out to be real work: the transcripts show those sessions mid-turn, and a backend restart starts both sides empty anyway. This path is a separate bug, found reading the code.

What changes

  • relay.SessionClosed also drops the session's turn and reported state. The id reopened is a new process that has reported nothing yet.
  • New relay.SessionExited, which the terminal calls once it reaps a process that ended on its own:
    • wired through terminal.SetSessionExited;
    • called from stream's reap, outside s.mu (the relay takes its own lock and calls into the terminal under it);
    • only when this reap evicted its own PTY, so a reap that lost the race to a respawn says nothing.
  • main.go wires it at startup, and denyInternal keeps relay.SessionExited and terminal.SetSessionExited off /rpc/ like their siblings.
  • CHANGELOG.md: a Fixed entry.

Tests

  • TestAReapedOrClosedSessionForgetsItsState (relay): busy, then exit or close, then neither the roster nor the turn map has the session. It fails without the fix ("busy after its process went").
  • TestAReapedSessionIsReportedToTheExitWatcher (terminal, real PTY): a process that exits on its own reaches the exit watcher; a close does not take that path.
  • gofmt -l . and go vet ./... clean, go test ./... green; the touched packages also ran with -race.

The relay dropped a session's turn state and roster entry only on idle
(SessionEnd), which a process killed or closed mid-turn never sends. The
same id respawned then read as busy to every delivery and in list_sessions
until its new process ended a turn of its own.

SessionClosed now drops that state too, and the terminal tells the relay
about a process that ended on its own once it is reaped
(terminal.SetSessionExited -> relay.SessionExited).
@omartelo
omartelo merged commit 335ee2c into main Oct 17, 2026
7 checks passed
@omartelo
omartelo deleted the fix/relay-forget-on-respawn branch October 17, 2026 17:58
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.

1 participant