Skip to content

server: enforce a terminal's deadline after the exit scan, not before - #215

Merged
pcarrier merged 1 commit into
mainfrom
eng/pty-supervisor-order
Aug 6, 2026
Merged

server: enforce a terminal's deadline after the exit scan, not before#215
pcarrier merged 1 commit into
mainfrom
eng/pty-supervisor-order

Conversation

@pcarrier

@pcarrier pcarrier commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #204, from reviewing the merged PTY lifecycle work.

The bug

supervise opened each pass with enforce_deadlines, which signals any terminal whose deadline or stop grace has come due. That is the wrong end of the pass, because reap_zombies — at the tail of the same pass — waits a child without marking its terminal exited; it has no way to reach the session.

So a child that dies between the exit scan and reap_zombies gets waited there, freeing its pid, while its Pty stays exited: false. The next pass then ran enforce_deadlines first, saw a live terminal, and — if the stop grace was due — sent kill(-pid, SIGKILL) at a pid the kernel had already released.

The distinction that matters: a zombie pins its process group, so signalling a not-yet-reaped child is safe. Signalling a reaped one aims at a pgid that is free to be reused. That is the only window where the !pty.exited guard does not hold, and reap_zombies is the only thing that opens it.

The fix

Move the call after the exit scan and its cleanup, still ahead of reap_zombies. Anything the previous pass waited is now marked exited by poll_child_exited — which reports a parked status — before deadlines are considered, and enforce_deadlines skips exited entries. That closes the window in both directions: a pid freed by this pass's reap_zombies is marked exited by the next pass's scan before this call can see it.

No behaviour change for a terminal that is actually alive. The deadline still fires in the pass its instant falls in, only later within it, and earliest_armed_deadline computes the wakeup independently.

Verification

Not covered by a test, deliberately stated rather than glossed: supervise needs an AppState, which nothing in the suite constructs — the deadline and retention policies are unit tested through the pure armed_deadline and slots_to_evict for exactly that reason, and neither is where this ordering lives.

Verified by reading the pass against reap_zombies, poll_child_exited and enforce_deadlines. Workspace clippy and cargo fmt clean, 287 blit-server tests passing.

`supervise` opened each pass with `enforce_deadlines`, which signals any
terminal whose deadline or stop grace has come due. That is the wrong
end of the pass, because `reap_zombies` — which runs at the tail of the
same pass — waits a child without marking its terminal exited.

The two together leave a window. A child that dies between the exit scan
and `reap_zombies` gets waited there, freeing its pid, but its `Pty`
stays `exited: false` because nothing in `reap_zombies` can reach the
session. The next pass then ran `enforce_deadlines` first, saw a live
terminal, and, if the stop grace was due, sent `kill(-pid, SIGKILL)` at
a pid the kernel had already released. A zombie pins its process group,
so signalling a not-yet-reaped child is safe; signalling a reaped one
aims at a pgid that is free to be reused.

Move the call after the exit scan and its cleanup, still ahead of
`reap_zombies`. Anything the previous pass waited is now marked exited
by `poll_child_exited` — which reports a parked status — before
deadlines are considered, and `enforce_deadlines` skips exited entries.
That closes the window in both directions: a pid freed by this pass's
`reap_zombies` is marked exited by the next pass's scan before this call
can see it.

No behaviour change for a terminal that is actually alive: the deadline
still fires in the pass its instant falls in, only later within it, and
`earliest_armed_deadline` computes the wakeup independently.

Not covered by a test. `supervise` needs an `AppState`, which nothing in
the suite constructs — the deadline and retention policies are unit
tested through the pure `armed_deadline` and `slots_to_evict` for that
reason, and neither is where this ordering lives. Verified by reading
the pass against `reap_zombies`, `poll_child_exited` and
`enforce_deadlines`; workspace clippy and `cargo fmt` clean, 287
blit-server tests passing.

Co-Authored-By: Claude <noreply@anthropic.com>
@indent

indent Bot commented Aug 6, 2026

Copy link
Copy Markdown
PR Summary

Reorders one call in the PTY supervisor to close a narrow window where the deadline stop sequence could signal a released process group. Previously supervise ran enforce_deadlines at the top of each pass, but reap_zombies (at the pass tail) waits a child — freeing its pid/pgid — without marking the terminal exited. So the next pass's enforce_deadlines could see a still-!exited terminal whose pid was already gone and send kill(-pid, SIGKILL) at a pgid the kernel had freed (and possibly reused).

  • Move enforce_deadlines(state).await from the start of supervise to after the exit scan and cleanup_pty_internal, still ahead of reap_zombies.
  • The exit scan now marks any child reaped by the previous pass as exited (via the status parked in poll_child_exited) before deadlines are considered, and enforce_deadlines skips exited entries — closing the window in both directions.
  • No behavior change for a live terminal: the deadline still fires in the pass its instant falls in, just later within that pass; earliest_armed_deadline computes the wakeup independently.

Issues

Review closed.

View session

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown

Coverage

Crate Lines Functions Regions
alacritty-driver 72.7% (806/1109) 75.0% (63/84) 76.2% (1310/1719)
browser 0.0% (0/822) 0.0% (0/68) 0.0% (0/1401)
cli 24.9% (2436/9801) 36.4% (300/825) 27.5% (4128/15020)
compositor 12.2% (1221/9999) 22.8% (111/487) 12.3% (1651/13411)
fonts 81.4% (721/886) 88.6% (70/79) 83.0% (1427/1719)
fssync 92.5% (4881/5274) 94.1% (445/473) 92.5% (8927/9647)
gateway 25.5% (375/1469) 29.9% (38/127) 19.2% (470/2448)
git 87.4% (4188/4793) 88.3% (331/375) 87.0% (6626/7614)
lsp 76.0% (2503/3295) 78.2% (248/317) 73.8% (3886/5266)
proxy 19.2% (172/898) 20.5% (26/127) 21.0% (293/1392)
remote 90.3% (9306/10302) 93.2% (681/731) 88.5% (15677/17723)
sd-notify 73.9% (68/92) 100.0% (6/6) 83.2% (109/131)
server 40.3% (7837/19424) 54.9% (775/1411) 42.9% (13259/30885)
ssh 32.2% (165/512) 48.2% (27/56) 31.4% (261/830)
upsidedown 31.4% (391/1247) 27.8% (55/198) 34.8% (797/2287)
webrtc-forwarder 2.7% (72/2624) 2.1% (4/187) 1.2% (50/4335)
webserver 62.1% (1133/1825) 65.9% (164/249) 64.5% (1912/2964)
Total 48.8% (36275/74372) 57.7% (3344/5800) 51.2% (60783/118792)

@pcarrier
pcarrier merged commit 238ff5f into main Aug 6, 2026
11 checks passed
@pcarrier
pcarrier deleted the eng/pty-supervisor-order branch August 6, 2026 03:09
@pcarrier

pcarrier commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Correct, and a genuine miss on my part — thanks for chasing it.

What stings is that #204's own description named this hazard and then failed to look for it: "group kill widens a pid-reuse race — kill(-pid) after the child is reaped hits an unrelated process group, where kill(pid) hit one process. The !pty.exited guard is the only thing holding it." I wrote that, then never asked what could make the guard false while the pid was already gone. reap_zombies is the answer, and it was in the same function.

The framing in the PR body is the part I'd missed entirely: a zombie pins its process group, so signalling a not-yet-reaped child is harmless — the danger only starts at the wait. That is what makes reap_zombies the unique opener of the window rather than one of several.

I re-derived the invariant against main to check nothing else opens it. Every path that frees a PTY pid:

  • collect_exit_status (:311) — only reached from cleanup_pty_internal, which sets exited in the same critical section.
  • abandon_pty_pid (:363) — only from C2S_CLOSE, which has already removed the entry from ptys, so there is no terminal left to signal.
  • reap_zombies' owned sweep (:395) and the PID-1 orphan drain (:416) — both park the status rather than dropping it, so the next pass's poll_child_exited reports exited. With enforce_deadlines now after the scan, that happens before deadlines are considered.
  • the abandoned sweep (:403) — those entries are gone from the map already.

So the reordering closes it completely, not just narrowly. Nothing else in the tree frees a pid while leaving a live Pty pointing at it.

One consequence worth writing down somewhere, since it is now load-bearing and invisible: reap_zombies must stay after the exit scan, and any future reaper added to supervise inherits the same constraint. The ordering comment covers the current call; a future one might not think to look.

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