From 32b4245962b75916be0ebe89225375430e03c36f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 31 Jul 2026 03:51:34 -0300 Subject: the capability handshake cannot lose a race it never runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queryTerminal(2ms) blocks on a futex until DA1 comes back, and the number has to beat one terminal round trip: local answers in microseconds, ssh localhost under a millisecond, any real link never. Measured against sshd with the replies delayed to model the wire, 2ms already loses at 5ms RTT. Losing it is worse than never probing, because vaxis splits detect from enable and only detect respects the deadline. The flag flips the moment the futex times out, so the two replies gated on it — explicit width and scaled text, both spelled as a cursor-position report — stop being read as probe replies and arrive at the app as shift-F3 and alt-F3 keypresses, while the ungated ones keep mutating caps from the reader thread long after enable already declined to switch those modes on. Over ssh the terminal sat in its default modes while caps claimed otherwise: kitty keyboard was never actually pushed, ever. So send the probes and resolve them on the loop. DA1 is last and terminals answer in order, so when the reader flips the flag every earlier reply is applied — no window to miss at any latency. Verified over real ssh at 5 through 500ms RTT: 7/7 caps and kitty keyboard actually enabled at every one, where before it was 5/7 and never. Startup is 2ms faster, no golden moves (nothing answers in the harness, and with no caps enable writes no bytes). Honest scope: I could not reproduce the reported stale characters, only the handshake bug behind them. The width half of the theory is inert — vaxis's Cell.width defaults to 1 and pardes writes one codepoint per cell with an explicit spacer, so gwidth, the only consumer of caps.unicode, is never called. That is written down so nobody re-derives it. If the dirty screen survives, the next suspect is vaxis's own carry-over for an escape sequence split across a read boundary (Loop.zig:174-190, wrong length and an off-by-one): four wheel events sent whole scroll four notches, the same four split at a `;` with a 60ms gap scroll zero. Network framing is exactly what makes those gaps. It is an input bug in a vendored dep and wants its own change. --- src/tty/tty.zig | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index 007abd49..b4bd4175 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -161,7 +161,47 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // resize watcher: plain detached thread (not io.concurrent — teardown // joins those, and sigwait never returns); dies with the process (try std.Thread.spawn(.{}, winchWatch, .{ &loop, &vx, &tty })).detach(); - vx.queryTerminal(tty.writer(), std.Io.Duration.fromMilliseconds(2)) catch {}; + // Capability handshake — SEND the probes, do not wait on them. This was + // queryTerminal(2ms), which blocks on a futex until DA1 comes back. The + // number has to beat one terminal round trip: a local terminal answers in + // microseconds, `ssh localhost` in under 1ms, and any real link never. + // Measured over sshd on :22 with the replies delayed to model the wire, + // 2ms already loses at 5ms RTT and everything above. + // + // Losing it is worse than never probing, because vaxis splits detect from + // enable and only detect respects the deadline. queryTerminal sets + // queries_done the moment the futex times out, and the two replies vaxis + // gates on that flag — explicit width and scaled text, both answered as a + // cursor-position report — stop being recognised as probe replies and are + // handed to US as shift-F3/alt-F3 keypresses. The replies it does NOT + // gate (mode 2027, kitty keyboard/graphics, sgr-pixels) keep landing and + // keep mutating vx.caps from the reader thread, long after + // enableDetectedFeatures ran and declined to switch those modes on. So + // over ssh the terminal sat in its default modes while caps claimed + // otherwise — kitty keyboard was never actually pushed, ever. Raising the + // timeout only moves the link speed at which that happens. + // + // Resolve it on the loop instead. DA1 is last in the probe string and + // terminals answer in order, so when vaxis's reader flips queries_done + // every earlier reply is already applied — no window left to miss at any + // latency, and the enable lands before the next frame (see caps_pending + // in the render path). A terminal that never answers keeps the defaults, + // which is what the 2ms timeout produced anyway, and with no caps + // enableDetectedFeatures writes no bytes — the snapshot goldens, where + // nothing ever answers, do not move. Startup gets 2ms faster, not slower. + // + // ponytail: nothing wakes the loop for DA1 alone. In practice the reply + // burst carries the mode-2048 size report too, which posts a winsize and + // turns the loop; a terminal idle from boot that lands DA1 between two + // parks keeps the defaults until the user's first keystroke (decoded + // legacy, which vaxis handles). Ceiling accepted because nothing in the + // render path reads the missing caps: pardes writes one codepoint per + // cell plus an explicit blank spacer under a wide glyph, and vaxis's + // Cell.width defaults to 1, so gwidth — the only consumer of + // caps.unicode — is never called. If that ever changes, wake the loop on + // vx.query_futex from a one-shot thread instead. + try vx.queryTerminalSend(tty.writer()); + var caps_pending = true; // now threads are fine: start a reader task per pty for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| { @@ -287,6 +327,20 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); }; + // The handshake landed (vaxis's reader flips queries_done on DA1, the + // last probe answered): put the terminal into the modes the caps now + // claim, before anything is drawn under them. Polled here rather than + // done where the replies arrive because that is the reader thread, and + // this is the only thread allowed to touch the tty writer. The repaint + // matters as much as the enable: earlier frames were drawn under the + // pre-handshake caps, and vaxis's shadow grid has to be re-established + // under the new ones or it keeps skipping cells it thinks are current. + if (caps_pending and vx.queries_done.load(.unordered)) { + caps_pending = false; + vx.enableDetectedFeatures(tty.writer()) catch {}; + vx.queueRefresh(); + } + // ---- render: surface -> vaxis, cell for cell ---- _ = frame_arena.reset(.retain_capacity); const tz_core = tracy.zone(@src(), "core.render"); -- cgit v1.3