Skip to content

Session exit delivers its tail before closing subscribers (CI flake root-caused) - #3

Merged
karngyan merged 5 commits into
mainfrom
worktree-session-exit-drain
Aug 8, 2026
Merged

Session exit delivers its tail before closing subscribers (CI flake root-caused)#3
karngyan merged 5 commits into
mainfrom
worktree-session-exit-drain

Conversation

@karngyan

@karngyan karngyan commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The flake, diagnosed

TestCloseTerminatesBackgroundChildren failed twice on CI (runs 31212710318, 31259207719) with the same signature: subscriber closed over an empty ring in the test's first millisecond. Root cause: markExitedLocked dropped every subscriber the moment the leader was reaped, and a child that writes-then-exits can be reaped on Linux while its final bytes still sit unread in the pty buffer — the supervisor's 5ms poll beating the pump's read. Reproduced deterministically in a Linux container by starving the pump: the old code fails with the flake's exact message.

Why only Linux: Darwin blocks a session leader's exit until its pty is drained (measured — ps state E through the whole window), so the reap structurally cannot win there. This is also a real product bug, not just a test one: a session whose process exits with output in flight lost that tail's delivery — exit pill before final lines.

The fix: drain-then-drop

markExitedLocked records the exit and defers the subscriber drop to whoever can prove the drain finished:

  • the pump, after a chunk that leaves the master unreadable (sole reader — its quiet verdict is authoritative), or at stream end;
  • the supervisor, after two looks ≥5ms apart agree: master polls quiet, pump parked inside a read, ring seq and read counter unmoved between them;
  • an exit recorded after stream end drops on the spot.

The supervisor's own retirement is gated on the drain having settled, so it can never exit leaving subscribers with no closer.

Measured along the way and now in the docs: Linux hangs the pty up when the session leader exits — a setsid'd grandchild's open slave fds do not keep the master alive, and buffered output survives the hangup. noteMasterEnded's platform notes were corrected accordingly.

Verification

  • Old code + starved pump on Linux: fails with the CI signature. New code + same starvation: tail delivered with the reap winning by 250ms.
  • New tests: TestExitDeliversTheTailBeforeClosingSubscribers (invariant tripwire), TestMasterReadable (the poll probe), TestExitWithOutOfGroupSlaveHolderClosesSubscribers (prompt close with an out-of-group slave holder; comment honest about which mechanism it exercises).
  • internal/session clean under -race -count=2 on Darwin and -count=2 in a Linux container; full go test ./... clean.
  • Two adversarial review rounds (whole-fix, then scoped); all findings addressed.

🤖 Generated with Claude Code

karngyan and others added 5 commits August 8, 2026 19:06
markExitedLocked dropped every subscriber the moment the leader was
reaped, and a child that writes and exits in the same breath can be
reaped on Linux while its last bytes still sit unread in the pty buffer
— the supervisor's 5ms poll beating the pump to them. Subscribers then
died over an empty ring, which is the CI flake recorded in FOLLOW-UPS
(two sightings, same signature to the byte). Darwin cannot lose this
race at all: a session leader's exit blocks until its pty is drained,
which is why 130 loaded local runs never caught it.

The exit now drains before it drops. markExitedLocked defers the drop
to whoever can prove the drain finished: the pump, after the chunk that
leaves the master with nothing readable — its verdict is authoritative,
being the only reader — or the supervisor, after two of its polls agree
the master is quiet and the ring has not grown between them, which
covers a pump parked in a read that will never return (a background job
holding the slave in silence). A stream that ends with the exit already
recorded closes the subscribers itself, and an exit recorded after the
stream ended drops them on the spot, so every ordering has exactly one
closer.

Reproduced deterministically in a Linux container by starving the pump:
the old code fails with the flake's exact signature, the new code
delivers the tail even with the reap winning by 250ms. Pinned by
TestExitDeliversTheTailBeforeClosingSubscribers and TestMasterReadable;
session package clean under -race on Darwin and in a Linux container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…retires

Three findings from the adversarial review, plus one measurement that
reshaped the story.

The supervisor could return with the drain still pending: a job-control
background job lives in a process group of its own, so the leader's
group can empty at the very reap that armed the drain, and a supervisor
gated only on the group being empty retired after a single look. Its
exit is now gated on drainSettled.

The two-look quiet rule was a schedule, not a proof: signal requests
and the master hint wake the select, compressing consecutive looks to
microseconds, and a pump descheduled between its read returning and
taking the lock holds a tail no poll can see. Looks now count only
when reapPollMin apart on the wall clock, and the pump bumps an atomic
read counter before it can take the lock — a tail in flight at one look
has moved the counter by the next.

The session field exitDrain collided with the daemon's exitDrain
duration; renamed drainPending.

And the measurement: Linux hangs the tty up when the session leader
exits — a setsid'd grandchild's open slave descriptors do not keep the
master alive, buffered output survives the hangup and is delivered
before the error. So the pump-end path, not the supervisor fallback, is
the working closer on both platforms; noteMasterEnded's platform notes
now say so, and the new out-of-group-holder test pins the prompt close
while its comment is honest about which mechanism it can and cannot
exercise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The re-review caught the counter comment claiming more than the counter
delivers: a tail lifted off the master before the first look leaves the
counter equal at both. The pump now flags itself parked only while it
is inside Read — holding nothing — and a look believes quiet only with
the pump parked and the counter still, which leaves the pump entering
Read empty-handed as the one unobservable instant, and that is the very
state quiet asserts. Comment stragglers from the rename fixed, the
platform note's coincide claim narrowed to the one the supervisor
actually rests on, and the out-of-group test's leader now lingers a
beat so its subscriber provably beats the drop, with a holder short
enough not to outlive the run it leaks from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR #2 recorded the second sighting and asked for instrumentation; this
branch is the diagnosis and the fix, so the merged note keeps the
second sighting's forensics and retires the instrumentation ask.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
internal/session imports unix for the master's poll; the marker just
catches up with the import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karngyan
karngyan merged commit 8b7a07c into main Aug 8, 2026
1 check passed
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