Skip to content

pty: escalate CLOSE to SIGKILL, and bound the aggregate S2C_LIST - #255

Merged
pcarrier merged 3 commits into
mainfrom
eng/pty-close-escalation-and-list-bound
Aug 7, 2026
Merged

pty: escalate CLOSE to SIGKILL, and bound the aggregate S2C_LIST#255
pcarrier merged 3 commits into
mainfrom
eng/pty-close-escalation-and-list-bound

Conversation

@pcarrier

@pcarrier pcarrier commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #213 and #214, one commit each.

Wire compat first: no protocol change in either direction. No new frame, no changed layout, no new feature bit. CLOSED still arrives the instant CLOSE is handled; the escalation is a server-side timer a client never sees. The S2C_LIST bound is a refusal at creation, and CREATE_FAILED only reaches a client that set CREATE2.WANT_STATUS. Old client / new server and new client / old server both behave exactly as before, except that a process which used to survive CLOSE now dies and a create that used to corrupt the catalog is now refused.


#213C2S_CLOSE escalates to a group SIGKILL

close_pty has signalled the process group with SIGHUP since #204, but stopped there, so the issue's repro survived:

blit terminal start -- bash -c 'trap "" HUP; sleep 600'
blit terminal close <id>   # the sleep keeps running

docs/design/units.md specifies the rest — SIGHUP to the group, wait TimeoutStopSec, SIGKILL to the group — and enforce_deadlines already implements that shape for expiry.

The design call: the escalation rides on the pid, not on a retained terminal. #213 framed this as CLOSE holding the slot in a "closing" state, and that is where all the cost is: such an entry is neither live nor exited, so live_ptys() counts it against --max-ptys while evict_exited (which keys off exited_at) never sees it, and CLOSED would have to start meaning "going" instead of "gone". None of that is needed to kill a process. CLOSE keeps removing the slot and broadcasting CLOSED synchronously; abandon_pty_pid takes the deadline for the SIGKILL, and the supervisor — which already wakes on armed timers — fires escalate_abandoned when it comes due.

Reaping beats escalating. A waited pid may already name an unrelated process group, so the reaper dropping a registration is also what disarms its pending kill, and the PID-1 orphan drain forgets the pid along with the status. The window in which kill(-pid) is valid is exactly the window in which the child is still ours. Windows needs none of this: close_pty drops the last handle to a KILL_ON_JOB_CLOSE job, so the hangup is already the kill, and the two new entry points are no-ops there.

Verified against a real binary on an isolated socket:

blit terminal close 1 returns 13 ms
HUP-ignoring child at t+0.5s, t+3s alive
at t+7s gone
catalog after close empty immediately
child that does answer SIGHUP dead and reaped inside 0.7 s, no zombie

#214 — the aggregate S2C_LIST bound

The ceiling the issue says is missing does exist, and it is not the 16 MiB frame size — the server fragments anything over 4 KiB, so a logical message is not frame-bounded. What binds is reassembly: MAX_DECOMPRESSED (64 MiB) is already enforced by every client that reassembles (read_message in blit-cli, BlitConnection in @blit-sh/core), and both abort the connection past it with no diagnostic at either end. A catalog the server cannot describe under that is a catalog nobody can be told about, so that is what a create is projected against, refused with BUDGET and a detail naming the number.

Creation is the whole surface: tag and command are fixed once a terminal exists (nothing renames one, RESTART clones both), so the catalog only ever grows by an entry a create put there. The projection is derived from ptys on each create rather than carried in a running total — a counter is a second record of the same fact, it drifts the first time a removal path forgets it, and a catalog that reports itself smaller than it encodes is precisely the desynchronizing frame this is meant to prevent. list_entry_bytes and push_list_entry are paired and pinned by a test, plus a debug_assert in the encoder against the projection it gets checked with.

Two gaps the audit turned up, both fixed here:

  • C2S_CREATE, CREATE_N and CREATE_AT never got server: own the PTY lifecycle — refusals, group kill, supervisor, deadlines, retention #204's per-field guard at all, and their command field has no length prefix — it runs to the end of a frame that may be 16 MiB. So a >64 KiB command still truncated into a corrupt catalog for every client on main. They refuse now too; having no failure reply, they refuse to the server log, as allocate_pty_id already does for the cap.
  • Connecting preflights the same projection before the client is registered, refusing the connection with a diagnostic instead of building a burst that would make the client hang up silently. Verified with a temporarily-shrunk ceiling: the client fails immediately with blit: server closed connection (exit 1) and the server stays healthy.

End-to-end with a temporary 40-byte ceiling, four terminals live:

blit: server refused to create terminal: budget exhausted
      (catalog would reach 43 bytes, over the 40-byte S2C_LIST ceiling)

Also replaces pty_list_msg_includes_tags, which was vacuous: it built its expected bytes by hand and asserted them against themselves without ever calling the encoder.

Out of scope, worth its own issue

surface_list_msg has the identical unbounded shape (title.len() as u16, app_id.len() as u16, no surface cap), and it is worse: title and app_id come from xdg_toplevel.set_title on a Wayland client, not from a blit request, so there is nothing to refuse and the fix has to be truncation or elision at encode time. msg_surface_created in blit-remote casts the same way. Untouched here.

Verification

cargo test -p blit-server --lib 330 passed; cargo fmt --all --check; cargo clippy --workspace -- -D warnings, plus the -p blit-server --all-targets and --no-default-features passes CI runs; prettier --check docs/protocol.md. Both new escalation tests were confirmed non-vacuous by removing the kill and watching them fail in 5 s (and they now carry a Drop guard, so a failure kills its forked group instead of leaving paused children holding the harness's stdout open).

View in Indent
Tag @indent to continue the conversation here.

@indent

indent Bot commented Aug 7, 2026

Copy link
Copy Markdown
PR Summary

Two independent server fixes (one commit each) that finish behaviors #204 started, with no wire-protocol change in either direction: CLOSED timing is unchanged, and the new S2C_LIST refusal only surfaces to a CREATE2 client that negotiated WANT_STATUS. A pushed third commit is prettier-only on three files main left unformatted, so the merge-with-main lint gate passes.

  • pty: C2S_CLOSE hangs up but never escalates to SIGKILL #213C2S_CLOSE escalates to a group SIGKILL. After the existing SIGHUP, the server now waits TimeoutStopSec (5 s) and SIGKILLs the group, so a child that ignores SIGHUP (trap "" HUP) no longer outlives its terminal. The escalation rides on the pid via a supervisor timer rather than a retained "closing" slot, so CLOSED still means "gone" and no entry counts against --max-ptys. Reaping cancels a pending SIGKILL, since a waited pid may name a recycled group. Windows is a no-op (the kill-on-close job already tears the tree down).
  • protocol: S2C_LIST can exceed the frame ceiling with no aggregate bound #214 — bound the aggregate S2C_LIST. A catalog whose encoded size would exceed what every client reassembles (MAX_DECOMPRESSED, 64 MiB) is undeliverable and silently drops the connection, so a create that would push it past the ceiling is now refused (BUDGET) across all create opcodes, deriving the projection from the live catalog rather than a drift-prone counter. This also closes server: own the PTY lifecycle — refusals, group kill, supervisor, deadlines, retention #204's gap where the legacy opcodes' unbounded command field could truncate into a corrupt catalog. Connecting preflights the same ceiling as a backstop.
  • Replaces the vacuous pty_list_msg_includes_tags test (which asserted hand-built bytes against themselves) with encoder-backed tests, and adds non-vacuous escalation tests with a group-kill drop guard.

Issues

Review closed.

CI Checks

All CI checks passed on 140cb49.

View session

@github-actions

github-actions Bot commented Aug 7, 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.7% (2436/9843) 36.2% (300/829) 27.4% (4128/15091)
compositor 17.9% (2178/12141) 29.5% (184/623) 19.1% (3209/16763)
fonts 81.4% (721/886) 88.6% (70/79) 83.0% (1427/1719)
fssync 92.5% (4879/5274) 94.1% (445/473) 92.5% (8925/9647)
gateway 25.5% (375/1469) 29.9% (38/127) 19.2% (470/2448)
git 87.5% (4192/4793) 88.3% (331/375) 87.1% (6634/7614)
lsp 76.2% (2513/3300) 78.5% (248/316) 74.1% (3906/5274)
proxy 19.2% (172/898) 20.5% (26/127) 21.0% (293/1392)
remote 90.5% (9354/10337) 93.3% (685/734) 88.6% (15743/17773)
sd-notify 72.8% (67/92) 100.0% (6/6) 82.4% (108/131)
server 42.4% (8969/21146) 57.0% (902/1583) 44.8% (14980/33423)
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 5.6% (153/2723) 8.5% (17/200) 4.6% (207/4516)
webserver 62.1% (1133/1825) 65.9% (164/249) 64.5% (1912/2964)
Total 49.1% (38504/78417) 58.1% (3561/6127) 51.5% (64310/124992)

CLOSE has signalled the child's process group with SIGHUP since #204, but
stopped there, so a child that ignores SIGHUP outlived its terminal:

    blit terminal start -- bash -c 'trap "" HUP; sleep 600'
    blit terminal close <id>   # the sleep survives

docs/design/units.md specifies the rest of the sequence — SIGHUP to the
group, wait TimeoutStopSec, SIGKILL to the group — and the deadline path
already implements that shape for expiry.

The escalation rides on the pid, not on a retained terminal. #213 framed
this as CLOSE holding the slot in a "closing" state, which is where the
design cost is: such an entry is neither live nor exited, so it counts
against --max-ptys through live_ptys() while evict_exited never sees it
(that keys off exited_at), and CLOSED would have to start meaning "going"
instead of "gone". None of that is needed to kill a process. CLOSE keeps
removing the slot and broadcasting CLOSED synchronously; abandon_pty_pid
takes the deadline for the SIGKILL, the supervisor arms its timer off it
and fires escalate_abandoned when it comes due.

Reaping beats escalating: a waited pid may already name an unrelated
process group, so the reaper dropping a registration is also what disarms
its pending kill, and the PID-1 orphan drain forgets the pid along with
the status. The window where the group signal is valid is exactly the
window where the child is still ours.

Windows needs none of it — close_pty drops the last handle to a
kill-on-job-close job, so the hangup is already the kill — and the wire is
untouched in both directions: no new frame, no changed layout, and CLOSED
arrives when it always did.
#204 refused a create whose tag or command cannot round-trip S2C_LIST's
u16 length prefix, and left the total unbounded. docs/protocol.md already
promised the other half — "a projected LIST overflow ... return BUDGET" —
and admitted it needed a logical-message ceiling that did not exist.

It does exist, and it is not the 16 MiB frame size: the server fragments
anything over 4 KiB, so a logical message is not frame-bounded. What binds
is reassembly, where MAX_DECOMPRESSED (64 MiB) is already enforced by
every client that reassembles — read_message in blit-cli and
BlitConnection in @blit-sh/core both abort the connection past it, with no
diagnostic at either end. A catalog the server cannot describe under that
is a catalog nobody can be told about, so that is what a create is
projected against, refused with BUDGET and a detail naming the number.

Creation is the whole surface. tag and command are fixed once a terminal
exists — nothing in the protocol renames one, and RESTART clones both —
so the catalog only grows by an entry a create put there. The projection
is derived from ptys on every create rather than carried in a running
total: a counter is a second record of the same fact, it drifts the first
time a removal path forgets it, and a catalog that reports itself smaller
than it encodes is precisely the desynchronizing frame this prevents.
list_entry_bytes and push_list_entry are paired and pinned by a test, with
a debug_assert in the encoder against the projection it is checked with.

Two gaps found on the way. C2S_CREATE, CREATE_N and CREATE_AT never got
#204's guard at all, and their command field has no length prefix — it
runs to the end of a frame that may be 16 MiB — so a >64 KiB command
still truncated into a corrupt catalog for every client. They now refuse
too; having no failure reply, they refuse to the log, as allocate_pty_id
already does for the cap. And connecting preflights the same projection
before registering the client, refusing the connection with a diagnostic
rather than building a burst that would make the client hang up silently.

Replaces the vacuous pty_list_msg_includes_tags, which built its expected
bytes by hand and asserted them against themselves without ever calling
the encoder.

No wire change in either direction: same frames, same layouts, and a
refusal that a client which never asked for CREATE_FAILED cannot see.
The lint gate has been red on main since 1cddeae: prettier --check rejects
docs/server.md, js/core/src/BlitConnection.ts, and
js/core/src/__tests__/audio-skip-servo.test.ts. None of them are touched by
this branch — CI checks the merge with main, so the failure lands here
anyway.

Purely mechanical, and version-independent: prettier 3.8.2 and 3.9.6 produce
byte-identical output for all three, and 3.8.2 reports the whole tree clean
afterwards.
@indent
indent Bot force-pushed the eng/pty-close-escalation-and-list-bound branch from 352be38 to 140cb49 Compare August 7, 2026 18:09
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

🔗 Preview: https://blit-ako0d6io6-indent.vercel.app

@pcarrier
pcarrier merged commit ffcc114 into main Aug 7, 2026
11 checks passed
@pcarrier
pcarrier deleted the eng/pty-close-escalation-and-list-bound branch August 7, 2026 20:14
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.

pty: C2S_CLOSE hangs up but never escalates to SIGKILL

1 participant