a73x

5d5b6fa5

perf: one bell per drain, and the count pinned on a real terminal

a73x   2026-08-15 20:53

Commit message
perf: one bell per drain, and the count pinned on a real terminal

The bell was the only client-bound stream on this protocol with no
coalescing: one frame per ring, six bytes on the wire and a writeAllFd on
every client's stdout apiece. 0x07 is about 1/256 of random bytes, so
`cat` on a binary produced ~256 frames per 64 KiB chunk — a 10 MB binary
is ~40,000 frames per client, for a sound that cannot ring 40,000 times.

Not a correctness bug; pending_cap is 8 MB and nobody was dropped. What
makes it a defect is this file's own discipline. sampleTermModes says a
frame per pty chunk "would be a bandwidth regression on a protocol whose
discipline is 'bytes proportional to what changed'". Modes dedup, titles
are sampled, the grid folds into one bounded delta. The bell was the sole
exception and the cheapest event to emit in bulk.

So: at most one bell per drain. Per DRAIN and not per session — a ring a
second later is a separate ring and gets its own frame; only a burst the
user could not have heard apart folds.

The pending slot is untouched, which is the half that could have gone
wrong. It is one slot per kind stamped with tracker.seq, and tracker.seq
does not move inside the loop, so 256 rings already left exactly what one
ring leaves: the same payload at the same seq, after 255 pointless
dupe-and-free pairs. Coalescing removes the churn and records the same
fact.

The e2e leg is here rather than with the feature because the feature did
not need one. The client's term_event dispatch is kind-agnostic, so the
clipboard leg already proves frame -> decode -> builder -> host tty for
the whole frame type, and a bell that merely arrived would have added no
coverage. Coalescing is what creates a transform only e2e can measure:
five rings in, one BEL out. A count of BEL bytes is only meaningful if
nothing else can contribute one, so the leg runs a daemon of its own (a
titled session tells every joiner, as ESC]0;...BEL) and asserts zero OSC
introducers in the capture before counting — which also covers the
session's own BEL-terminated OSC 133 marks.

Two unit tests carry the halves the e2e leg cannot: a burst folding to
one frame, and a later chunk still getting its own. Both ring only once
the attach has been observed. Written the obvious way they were flaky in
both directions — a script that rings immediately races the attach, the
drain finds no client to queue to, and a fresh attach replays no pending
event, so the ring is lost and the failure reads as "no bell" rather than
"too many".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

src/server.zig
Old New
@@ -2238,7 +2238,33 @@ pub const Server = struct {
2238 // of bells is a single allocation that the first event sizes. 2238 // of bells is a single allocation that the first event sizes.
2239 var payload: std.ArrayList(u8) = .empty; 2239 var payload: std.ArrayList(u8) = .empty;
2240 defer payload.deinit(self.alloc); 2240 defer payload.deinit(self.alloc);
2241 // At most one bell per drain. N rings inside one 64 KiB chunk are one
2242 // ring to a human, and the bell is the cheapest event a session can
2243 // emit in bulk: 0x07 is ~1/256 of random bytes, so `cat` on a binary
2244 // produces ~256 frames per chunk — 6 bytes on the wire and a
2245 // writeAllFd on every client's stdout apiece, for a sound that cannot
2246 // ring 256 times. sampleTermModes states the discipline this restores:
2247 // "bytes proportional to what changed". Modes dedup, titles are
2248 // sampled, the grid folds into one bounded delta; the bell was the
2249 // only client-bound stream left that did not.
2250 //
2251 // Per DRAIN, not per session. A bell a second later is a separate ring
2252 // and gets its own frame — only a burst the user could not have heard
2253 // apart folds. A session-lifetime flag would silence every bell after
2254 // the first, which is a different feature and a broken one.
2255 //
2256 // The pending slot is unaffected, which is worth stating because it is
2257 // the half that could have gone wrong: it is one slot per kind stamped
2258 // with tracker.seq, and tracker.seq does not move inside this loop. So
2259 // 256 rings recorded the old way left exactly what one ring leaves —
2260 // the same payload at the same seq, after 255 pointless dupe-and-free
2261 // pairs. Coalescing removes the churn and changes no recorded state.
2262 var rang = false;
2241 for (s.eng.sideEvents()) |ev| { 2263 for (s.eng.sideEvents()) |ev| {
2264 if (ev.kind == .bell) {
2265 if (rang) continue;
2266 rang = true;
2267 }
2242 payload.clearRetainingCapacity(); 2268 payload.clearRetainingCapacity();
2243 switch (ev.kind) { 2269 switch (ev.kind) {
2244 // What a failed encode gives up is this one event: a 2270 // What a failed encode gives up is this one event: a
@@ -7846,6 +7872,141 @@ test "Server: a BEL from the session reaches its client as a bell term_event" {
7846 } 7872 }
7847 } 7873 }
7848 7874
7875 test "Server: a burst of bells in one chunk is coalesced into one frame" {
7876 const alloc = std.testing.allocator;
7877
7878 var tmp = try TmpDir.make();
7879 defer tmp.cleanup();
7880 const sock_path = try std.fmt.allocPrint(alloc, "{s}/bellburst.sock", .{tmp.path()});
7881 defer alloc.free(sock_path);
7882
7883 // A script rather than `cat`, unlike the test above, and the count is the
7884 // reason. Typed input is echoed by the tty, so five typed BELs can reach
7885 // the engine TWICE — once as the echo and once written back — in two
7886 // chunks and therefore two honest drains. A file writes them once, in one
7887 // write, so "one frame" here is a statement about coalescing rather than
7888 // about what ECHOCTL happened to do.
7889 //
7890 // The `read` is not decoration, and this test failed both ways without it
7891 // before it was added. A script that rings at once races the attach: the
7892 // shell can reach printf before the client is in the session, and then the
7893 // drain has nobody to queue to. The event IS recorded pending — but a
7894 // fresh attach takes the snapshot branch, which replays nothing (a
7895 // reattach quoting a seq is what collects a gap), so the ring is simply
7896 // gone and the test fails claiming no bell rather than too many. Ringing
7897 // only once released proves the client was there to miss it.
7898 try tmp.dir.writeFile(.{
7899 .sub_path = "bellburst.sh",
7900 .data =
7901 \\#!/bin/sh
7902 \\read -r go
7903 \\printf '\007\007\007\007\007'
7904 \\exec sleep 30
7905 \\
7906 ,
7907 .flags = .{ .mode = 0o755 },
7908 });
7909 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/bellburst.sh", .{tmp.path()}, 0);
7910 defer alloc.free(script);
7911
7912 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
7913 defer srv.deinit();
7914
7915 const c = try std.net.connectUnixSocket(sock_path);
7916 defer c.close();
7917 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
7918 // Awaited, not assumed: this is what makes the ring below strictly later
7919 // than the attach, and so what removes the race described above.
7920 (try awaitFrame(alloc, &srv, c.handle, .snapshot, 400) orelse
7921 return error.NoSnapshot).deinit(alloc);
7922
7923 // `go`, never a BEL: with a bell typed here the tty's own echo could
7924 // arrive as a separate event in a separate chunk, and this test would be
7925 // counting ECHOCTL rather than coalescing.
7926 try proto.writeFrame(c.handle, .input, "go\n");
7927
7928 const ev = (try awaitFrame(alloc, &srv, c.handle, .term_event, 400)) orelse
7929 return error.NoTermEvent;
7930 defer ev.deinit(alloc);
7931 switch (try proto.decodeTermEvent(ev.payload)) {
7932 .bell => {},
7933 .clipboard => return error.ExpectedBellGotClipboard,
7934 }
7935
7936 // The boundary, with the positive already in hand so it cannot pass
7937 // vacuously: the other four rings must not have become four more frames.
7938 // The budget keeps pumping long past the drain that produced the first,
7939 // which is the very pump the rest would have been queued in.
7940 if (try awaitFrame(alloc, &srv, c.handle, .term_event, 60)) |extra| {
7941 extra.deinit(alloc);
7942 return error.BellsNotCoalesced;
7943 }
7944 }
7945
7946 test "Server: a bell in a later chunk is its own frame, not folded into the first" {
7947 const alloc = std.testing.allocator;
7948
7949 var tmp = try TmpDir.make();
7950 defer tmp.cleanup();
7951 const sock_path = try std.fmt.allocPrint(alloc, "{s}/belltwice.sock", .{tmp.path()});
7952 defer alloc.free(sock_path);
7953
7954 // Coalescing is PER DRAIN, and this is the half of that choice the burst
7955 // test above cannot see: hoisting the flag to session state would pass it
7956 // and silence every ring after the first for the session's whole life.
7957 //
7958 // Each ring is released by its own `read`, which buys two things. It puts
7959 // the two rings in separate chunks — so the second is a drain of its own
7960 // rather than a hoped-for split of one write — and it puts BOTH strictly
7961 // after the attach, which is the race the burst test above documents: a
7962 // shell that rings before the client is in the session has nobody to queue
7963 // to, and a fresh attach replays no pending event.
7964 //
7965 // What gets typed to release them is `go`, never a BEL. With `cat` the
7966 // echo of a typed bell could itself arrive as a second event, and this
7967 // test would pass on the leftovers of the first ring while the second was
7968 // being swallowed — green for precisely the bug it exists to catch.
7969 try tmp.dir.writeFile(.{
7970 .sub_path = "belltwice.sh",
7971 .data =
7972 \\#!/bin/sh
7973 \\read -r one
7974 \\printf '\007'
7975 \\read -r two
7976 \\printf '\007'
7977 \\exec sleep 30
7978 \\
7979 ,
7980 .flags = .{ .mode = 0o755 },
7981 });
7982 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/belltwice.sh", .{tmp.path()}, 0);
7983 defer alloc.free(script);
7984
7985 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
7986 defer srv.deinit();
7987
7988 const c = try std.net.connectUnixSocket(sock_path);
7989 defer c.close();
7990 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
7991 (try awaitFrame(alloc, &srv, c.handle, .snapshot, 400) orelse
7992 return error.NoSnapshot).deinit(alloc);
7993
7994 try proto.writeFrame(c.handle, .input, "one\n");
7995 const first = (try awaitFrame(alloc, &srv, c.handle, .term_event, 400)) orelse
7996 return error.NoFirstBell;
7997 first.deinit(alloc);
7998
7999 try proto.writeFrame(c.handle, .input, "two\n");
8000
8001 const second = (try awaitFrame(alloc, &srv, c.handle, .term_event, 400)) orelse
8002 return error.SecondBellSwallowed;
8003 defer second.deinit(alloc);
8004 switch (try proto.decodeTermEvent(second.payload)) {
8005 .bell => {},
8006 .clipboard => return error.ExpectedBellGotClipboard,
8007 }
8008 }
8009
7849 // --------------------------------------------------------------------------- 8010 // ---------------------------------------------------------------------------
7850 // The gap: a side-channel event produced while nobody was attached. 8011 // The gap: a side-channel event produced while nobody was attached.
7851 // 8012 //
test/e2e.sh
Old New
@@ -209,6 +209,15 @@ QPORT5=$(( 56000 + ($$ % 4000) ))
209 M18KEY="${TMPDIR:-/tmp}/mux-e2e-m18key-$$" 209 M18KEY="${TMPDIR:-/tmp}/mux-e2e-m18key-$$"
210 D20PID="" 210 D20PID=""
211 211
212 # The bell leg's own daemon. A socket of its own is load-bearing rather than
213 # tidiness, and the argument is at the leg itself: that scenario counts BEL
214 # BYTES in a host capture, and a BEL is also what terminates an OSC. $SOCK's
215 # session has a window title set on it by the title leg, and every client
216 # attaching there is told so as `ESC]0;...BEL` — which would land in the
217 # capture and make the count assert something other than what it says.
218 SOCK24="${TMPDIR:-/tmp}/muxd-e2e-bell-$$.sock"
219 D21PID=""
220
212 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 221 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
213 # such line on exit; every field is a key=value pair, so a rename or reorder 222 # such line on exit; every field is a key=value pair, so a rename or reorder
214 # in the client shows up here as an empty read rather than a wrong number. 223 # in the client shows up here as an empty read rather than a wrong number.
@@ -722,6 +731,7 @@ cleanup() {
722 [ -n "$D18PID" ] && kill "$D18PID" 2>/dev/null || true 731 [ -n "$D18PID" ] && kill "$D18PID" 2>/dev/null || true
723 [ -n "$D19PID" ] && kill "$D19PID" 2>/dev/null || true 732 [ -n "$D19PID" ] && kill "$D19PID" 2>/dev/null || true
724 [ -n "$D20PID" ] && kill "$D20PID" 2>/dev/null || true 733 [ -n "$D20PID" ] && kill "$D20PID" 2>/dev/null || true
734 [ -n "$D21PID" ] && kill "$D21PID" 2>/dev/null || true
725 # The stops still precede the socket rm below, like SOCK14-17 above: 735 # The stops still precede the socket rm below, like SOCK14-17 above:
726 # unlinking a socket first would leave a live daemon nothing could reach 736 # unlinking a socket first would leave a live daemon nothing could reach
727 # by path. 737 # by path.
@@ -739,7 +749,7 @@ cleanup() {
739 "$D7PID" "$D9PID" "$D10PID" "$D12PID" "$D13PID" "$SPID" "$TPID" \ 749 "$D7PID" "$D9PID" "$D10PID" "$D12PID" "$D13PID" "$SPID" "$TPID" \
740 "$GPID" "$APID" "$PAPID" "$HAPID" "$HDPID" \ 750 "$GPID" "$APID" "$PAPID" "$HAPID" "$HDPID" \
741 "$D14PID" "$D15PID" "$D16PID" "$D17PID" "$D18PID" "$D19PID" \ 751 "$D14PID" "$D15PID" "$D16PID" "$D17PID" "$D18PID" "$D19PID" \
742 "$D20PID" 752 "$D20PID" "$D21PID"
743 _leak=0 753 _leak=0
744 leak_sweep "$_rc" || _leak=1 754 leak_sweep "$_rc" || _leak=1
745 755
@@ -796,6 +806,12 @@ cleanup() {
796 # ...and its other half: the paste capture and the file nvim wrote, which 806 # ...and its other half: the paste capture and the file nvim wrote, which
797 # IS that scenario's assertion rather than a log beside it. 807 # IS that scenario's assertion rather than a log beside it.
798 rm -f "$OUT.paste" "$OUT.paste.err" "$OUT.paste.log" "$OUT.pasted.txt" 808 rm -f "$OUT.paste" "$OUT.paste.err" "$OUT.paste.log" "$OUT.pasted.txt"
809 # ...and the bell leg's five: the four the other side-channel legs have,
810 # plus the daemon capture, since this leg runs a daemon of its own. The
811 # daemon capture is rm'd here and never mid-suite, so the leak sweep above
812 # still has it to read.
813 rm -f "$SOCK24" "$OUT.d21.d" \
814 "$OUT.bell" "$OUT.bell.err" "$OUT.bell.log" "$OUT.bell.sh"
799 # M14 handoff. The shim, both runtime dirs (each holding its daemon's 815 # M14 handoff. The shim, both runtime dirs (each holding its daemon's
800 # socket), the second key, the unusable config home, and the five 816 # socket), the second key, the unusable config home, and the five
801 # captures. 817 # captures.
@@ -3007,6 +3023,93 @@ grep -q '^ b = 2$' "$OUT.pasted.txt" || {
3007 rm_swept "$OUT.paste" "$OUT.paste.err" "$OUT.paste.log" "$OUT.pasted.txt" 3023 rm_swept "$OUT.paste" "$OUT.paste.err" "$OUT.paste.log" "$OUT.pasted.txt"
3008 ok "a paste into nvim keeps its indentation" 3024 ok "a paste into nvim keeps its indentation"
3009 3025
3026 # --- side channel: a burst of bells reaches the host as exactly one ------
3027 #
3028 # This leg exists for the COALESCING, not for the bell. The client's
3029 # term_event dispatch is kind-agnostic — every event goes through one
3030 # writeSideChannel call with no per-kind branch — so the clipboard leg above
3031 # already proves frame → decode → builder → host tty for the whole frame
3032 # type, and a bell that merely ARRIVED would add no coverage: the one
3033 # bell-specific line past the wire is pinned directly in client.zig. What
3034 # only e2e can see is the NUMBER. Five rings inside one pty chunk must reach
3035 # the host as one (server.zig, drainSideEvents), and a regression there is
3036 # silent — every assertion anyone would think to write about a bell still
3037 # passes while 256 frames per chunk go out.
3038 #
3039 # So the assertion is a count, and a count of BEL BYTES is only unambiguous
3040 # if nothing else in the capture can contribute one. Two things make that
3041 # true here, and both are asserted rather than assumed:
3042 #
3043 # 1. A daemon of its own. The client writes exactly two OSCs, ever —
3044 # `ESC]52;` and `ESC]0;` (client.zig), each BEL-terminated — and $SOCK's
3045 # session has had a title set on it by the leg above, which every client
3046 # attaching there is then told about. On a fresh daemon nothing sets a
3047 # title and nothing copies, so neither OSC has an occasion to be built.
3048 # 2. The zero-OSC check below, which is what turns that argument into a
3049 # measurement. With no `ESC]` anywhere in the capture, every BEL in it
3050 # is a bare bell — there is no sequence left for one to be terminating.
3051 #
3052 # The session's own OSC 133 marks are BEL-terminated too and do not reach
3053 # here, because the engine consumes them into cmd_state rather than
3054 # forwarding bytes. That is not an assumption either: check 2 would see them.
3055 "$MUXD" run --sock "$SOCK24" --shell /bin/sh > "$OUT.d21.d" 2>&1 &
3056 D21PID=$!
3057 wait_sock "$SOCK24" "$OUT.d21.d" "bell daemon socket never appeared"
3058
3059 # Five rings in ONE printf, which is one write and therefore one pty read and
3060 # one drain. Split across two writes they would be two chunks and two frames
3061 # — correctly, since coalescing is per drain — so the single printf is what
3062 # makes "one" the right answer rather than a number that depends on timing.
3063 #
3064 # Emitted by a FILE, never typed, for the M12 reason spelled out on the
3065 # clipboard leg: the shell echoes what is typed, so a needle that could
3066 # arrive as an echo would pass on a client that forwards nothing at all.
3067 cat > "$OUT.bell.sh" <<'BELLSH'
3068 printf '\007\007\007\007\007'
3069 printf 'BELLDONE\n'
3070 BELLSH
3071 set +e
3072 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.bell" --err "$OUT.bell.err" \
3073 -- "$MUX" --sock "$SOCK24" > "$OUT.bell.log" 2>&1 <<EOF
3074 expect \x1b[?1049h 15000
3075 settle 400 15000
3076 send sh $OUT.bell.sh\n
3077 expect BELLDONE 15000
3078 settle 400 15000
3079 send \x1c
3080 waitexit 10000
3081 EOF
3082 RC=$?
3083 set -e
3084 [ "$RC" -eq 0 ] || {
3085 echo "e2e FAIL: bell scenario did not run: ptyclient exited $RC"
3086 cat "$OUT.bell.log"; cat -v "$OUT.bell.err" 2>/dev/null; exit 1; }
3087 # The positive control, as on every leg here: the marker travels the ordinary
3088 # grid path, so without it "one bell" and "no session" are the same reading —
3089 # and "zero bells" would pass the zero-OSC check below just as well.
3090 grep -qa 'BELLDONE' "$OUT.bell" || {
3091 echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
3092 # -F, and it is not decoration: the needle is `ESC]`, and `]` unescaped makes
3093 # this a malformed bracket expression — grep exits 2 and `grep && { fail }`
3094 # reads the error as "not found". That exact defect has already shipped in
3095 # this file once (see the dead-transport leg).
3096 OSCINTRO=$(printf '\033]')
3097 grep -qaF "$OSCINTRO" "$OUT.bell" && {
3098 echo "e2e FAIL: an OSC reached the bell capture, so its BELs cannot be counted"
3099 cat -v "$OUT.bell"; exit 1; }
3100 # ...and now the count means what it says. `occurrences` is the title leg's
3101 # helper, defined above and reused here on the same kind of byte needle.
3102 BELBYTE=$(printf '\007')
3103 BELLN=$(occurrences "$OUT.bell" "$BELBYTE")
3104 [ "$BELLN" -eq 1 ] || {
3105 echo "e2e FAIL: $BELLN bells on the host tty for one burst of five, want exactly 1"
3106 cat -v "$OUT.bell"; exit 1; }
3107 "$MUXD" stop --sock "$SOCK24" > /dev/null 2>&1 || true
3108 wait_pid_gone "$D21PID" "bell leg: stop reported stopped"
3109 D21PID=""
3110 rm_swept "$OUT.bell" "$OUT.bell.err" "$OUT.bell.log" "$OUT.bell.sh" "$OUT.d21.d"
3111 ok "a burst of bells in one chunk reaches the host as exactly one"
3112
3010 # --- M14: the ssh→QUIC handoff ----------------------------------------- 3113 # --- M14: the ssh→QUIC handoff -----------------------------------------
3011 # 3114 #
3012 # `mux HOST` fetches QUIC coordinates over ssh once, caches them, and 3115 # `mux HOST` fetches QUIC coordinates over ssh once, caches them, and
@@ -4012,7 +4115,7 @@ DPID=""
4012 4115
4013 # The pins. Literals, not variables set from counting something else — 4116 # The pins. Literals, not variables set from counting something else —
4014 # "assert the literal, never the constant the code under test reads" 4117 # "assert the literal, never the constant the code under test reads"
4015 # (decisions.md, M10). 30 scenario checkpoints; 35 convergence points. 4118 # (decisions.md, M10). 31 scenario checkpoints; 35 convergence points.
4016 # Anyone adding a scenario updates these by hand, on purpose. 4119 # Anyone adding a scenario updates these by hand, on purpose.
4017 # 4120 #
4018 # M18 added three checkpoints and no convergence points: its wall block 4121 # M18 added three checkpoints and no convergence points: its wall block
@@ -4026,9 +4129,12 @@ DPID=""
4026 # added the 29th, and no convergence point for the side-channel reason 4129 # added the 29th, and no convergence point for the side-channel reason
4027 # again: it asserts on a capture, for bytes no grid carries. The 30th is 4130 # again: it asserts on a capture, for bytes no grid carries. The 30th is
4028 # that leg's negative twin — the same bytes, asserted ABSENT from a client 4131 # that leg's negative twin — the same bytes, asserted ABSENT from a client
4029 # that took no terminal over — and carries no convergence point either. 4132 # that took no terminal over — and carries no convergence point either. The
4030 [ "$OK_COUNT" = "30" ] || { 4133 # 31st is the bell burst, and no convergence point for the side-channel
4031 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 30 —" 4134 # reason a third time: what it asserts on is a COUNT of bytes in a capture,
4135 # and a grid carries neither the bytes nor the number of them.
4136 [ "$OK_COUNT" = "31" ] || {
4137 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 31 —"
4032 echo " a scenario was added (update the pin) or silently lost" 4138 echo " a scenario was added (update the pin) or silently lost"
4033 exit 1 4139 exit 1
4034 } 4140 }
@@ -4036,4 +4142,4 @@ DPID=""
4036 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 4142 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
4037 exit 1 4143 exit 1
4038 } 4144 }
4039 echo "e2e OK (30 scenarios, 35 convergence points)" 4145 echo "e2e OK (31 scenarios, 35 convergence points)"