a73x

b756779f

refactor: three client-side twins get one owner each

a73x   2026-08-29 10:01

Commit message
refactor: three client-side twins get one owner each

waitReady and readAnnounceAbortable built the same two-descriptor poll and
drained the abort fd the same way, down to the EOF that stops the watch;
abortPoll and drainAbortFd own both, so the abort key cannot keep working
on one wait and stop on the other. mux_paste_begin and mux_paste_end
differed only in which keymap marker they copied — pasteMarker takes it.
encodeSelectionReply and decodeSelectionReply repeated the status/text
rule; checkSelectionText is the one rule, so a writer can never mint a
frame its reader refuses.

The wasm exports keep their names: mux.js calls them by name. Pinned by
protocol.zig's selection round-trip tests, client.zig's announce-abort
tests and web/verify.js's paste lengths.

src/client/client.zig
Old New
@@ -751,36 +751,41 @@ fn waitReady(
751 // uncapped retry loop is justified by the user having an abort key, 751 // uncapped retry loop is justified by the user having an abort key,
752 // so an abort key that stops working mid-handshake takes the 752 // so an abort key that stops working mid-handshake takes the
753 // justification with it. 753 // justification with it.
754 var fds = [_]std.posix.pollfd{ 754 var fds = abortPoll(cl.pollFd(), abort_fd, watch_stdin);
755 .{ .fd = cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
756 .{
757 .fd = if (watch_stdin) abort_fd else -1,
758 .events = std.posix.POLL.IN,
759 .revents = 0,
760 },
761 };
762 _ = std.posix.poll(&fds, cl.timeoutMs(50)) catch break; 755 _ = std.posix.poll(&fds, cl.timeoutMs(50)) catch break;
763 if (fds[1].revents != 0) { 756 // On a first attach the bytes drained here are the user's first
764 var buf: [1024]u8 = undefined; 757 // keystrokes and are owed to the shell, so `carry` keeps them.
765 const n = std.posix.read(abort_fd, &buf) catch 0; 758 if (fds[1].revents != 0) watch_stdin = try drainAbortFd(abort_fd, alloc, carry);
766 if (n == 0) watch_stdin = false;
767 if (n > 0) {
768 if (std.mem.indexOfScalar(u8, buf[0..n], keymap.detach_key) != null) return error.UserAbort;
769 // Not the abort key. Whether these bytes are kept or dropped
770 // is the caller's policy, not this function's: on a first
771 // attach they are the user's first keystrokes and are owed to
772 // the shell, while during a reconnect input is dropped by
773 // long-standing policy — replaying a burst of stale
774 // keystrokes on resume is worse than losing them.
775 if (carry) |q| q.appendSlice(alloc, buf[0..n]) catch {};
776 }
777 }
778 } 759 }
779 cl.pump(); 760 cl.pump();
780 if (cl.isReady()) return; 761 if (cl.isReady()) return;
781 return error.QuicHandshakeFailed; 762 return error.QuicHandshakeFailed;
782 } 763 }
783 764
765 /// The two-descriptor wait both handshake paths make: the thing being
766 /// waited on, and the abort key beside it. An unwatched abort fd polls -1,
767 /// which poll(2) ignores.
768 fn abortPoll(main_fd: std.posix.fd_t, abort_fd: std.posix.fd_t, watching: bool) [2]std.posix.pollfd {
769 return .{
770 .{ .fd = main_fd, .events = std.posix.POLL.IN, .revents = 0 },
771 .{ .fd = if (watching) abort_fd else -1, .events = std.posix.POLL.IN, .revents = 0 },
772 };
773 }
774
775 /// Drain the abort fd; false means stop watching it, because a closed one
776 /// stays readable forever and the caller's wait would spin hot. Bytes that
777 /// are not the abort key are the caller's policy: `carry` keeps them, null
778 /// drops them — a reconnect drops, since replaying a burst of stale
779 /// keystrokes on resume is worse than losing them.
780 fn drainAbortFd(abort_fd: std.posix.fd_t, alloc: std.mem.Allocator, carry: ?*std.ArrayList(u8)) error{UserAbort}!bool {
781 var buf: [1024]u8 = undefined;
782 const n = std.posix.read(abort_fd, &buf) catch 0;
783 if (n == 0) return false;
784 if (std.mem.indexOfScalar(u8, buf[0..n], keymap.detach_key) != null) return error.UserAbort;
785 if (carry) |q| q.appendSlice(alloc, buf[0..n]) catch {};
786 return true;
787 }
788
784 /// Deliberately NO deadline: a timer here races a cold `mux d endpoint` 789 /// Deliberately NO deadline: a timer here races a cold `mux d endpoint`
785 /// spawn, and the abort key already covers a hung ssh. 790 /// spawn, and the abort key already covers a hung ssh.
786 fn readAnnounceAbortable( 791 fn readAnnounceAbortable(
@@ -802,34 +807,15 @@ fn readAnnounceAbortable(
802 // waiting for one that may never come would turn an error into a hang. 807 // waiting for one that may never come would turn an error into a hang.
803 if (n == buf.len) return error.LineTooLong; 808 if (n == buf.len) return error.LineTooLong;
804 809
805 var fds = [_]std.posix.pollfd{ 810 var fds = abortPoll(fd, abort_fd, watch_stdin);
806 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
807 .{
808 .fd = if (watch_stdin) abort_fd else -1,
809 .events = std.posix.POLL.IN,
810 .revents = 0,
811 },
812 };
813 // No timeout, per the note above. std.posix.poll retries EINTR 811 // No timeout, per the note above. std.posix.poll retries EINTR
814 // itself, so a SIGWINCH mid-wait is not an error to handle here. 812 // itself, so a SIGWINCH mid-wait is not an error to handle here.
815 _ = try std.posix.poll(&fds, -1); 813 _ = try std.posix.poll(&fds, -1);
816 814
817 if (fds[1].revents != 0) { 815 // `openHandoff` always passes `carry` null: on a first attach this
818 var in: [1024]u8 = undefined; 816 // fd is -1 and never read — the keystrokes wait in the kernel's tty
819 const got = std.posix.read(abort_fd, &in) catch 0; 817 // buffer for ssh's prompt, then the shell.
820 if (got == 0) watch_stdin = false; 818 if (fds[1].revents != 0) watch_stdin = try drainAbortFd(abort_fd, alloc, carry);
821 if (got > 0) {
822 if (std.mem.indexOfScalar(u8, in[0..got], keymap.detach_key) != null)
823 return error.UserAbort;
824 // `openHandoff` always passes `carry` null: on a first
825 // attach this fd is -1 and never read — the keystrokes
826 // wait in the kernel's tty buffer for ssh's prompt, then
827 // the shell — and on a reconnect input is dropped by
828 // policy: replaying stale keystrokes on resume is worse
829 // than losing them.
830 if (carry) |q| q.appendSlice(alloc, in[0..got]) catch {};
831 }
832 }
833 819
834 if (fds[0].revents == 0) continue; 820 if (fds[0].revents == 0) continue;
835 // One byte, because the frame stream begins at the byte after the 821 // One byte, because the frame stream begins at the byte after the
src/client/wasm_core.zig
Old New
@@ -485,25 +485,18 @@ export fn mux_text_encode(len: u32) i32 {
485 return @intCast(len); 485 return @intCast(len);
486 } 486 }
487 487
488 /// Each marker on its own, because a paste too big for one message is
489 /// still ONE paste: begin, N unwrapped chunks through mux_text_encode,
490 /// end. Wrapping each chunk would put a paste-END mid-text, and vim acts
491 /// on it right there — paste mode off 32 KiB in, the rest re-indented.
492 export fn mux_paste_begin() i32 { 488 export fn mux_paste_begin() i32 {
493 const c = core orelse { 489 return pasteMarker(keymap.paste_begin);
494 output_len = 0;
495 return 0;
496 };
497 if (!c.client.terminal_modes.bracketed_paste) {
498 output_len = 0;
499 return 0;
500 }
501 @memcpy(output_buf[0..keymap.paste_begin.len], keymap.paste_begin);
502 output_len = @intCast(keymap.paste_begin.len);
503 return @intCast(keymap.paste_begin.len);
504 } 490 }
505 491
506 export fn mux_paste_end() i32 { 492 export fn mux_paste_end() i32 {
493 return pasteMarker(keymap.paste_end);
494 }
495
496 /// Each marker on its own: a paste too big for one message is still ONE
497 /// paste, and wrapping every chunk would put a paste-END mid-text — vim
498 /// acts on it there. Nothing at all without bracketed paste.
499 fn pasteMarker(marker: []const u8) i32 {
507 const c = core orelse { 500 const c = core orelse {
508 output_len = 0; 501 output_len = 0;
509 return 0; 502 return 0;
@@ -512,9 +505,9 @@ export fn mux_paste_end() i32 {
512 output_len = 0; 505 output_len = 0;
513 return 0; 506 return 0;
514 } 507 }
515 @memcpy(output_buf[0..keymap.paste_end.len], keymap.paste_end); 508 @memcpy(output_buf[0..marker.len], marker);
516 output_len = @intCast(keymap.paste_end.len); 509 output_len = @intCast(marker.len);
517 return @intCast(keymap.paste_end.len); 510 return @intCast(marker.len);
518 } 511 }
519 512
520 // --------------------------------------------------------------------- 513 // ---------------------------------------------------------------------
src/engine/protocol.zig
Old New
@@ -414,6 +414,21 @@ pub const SelectionReply = struct {
414 text: []const u8, 414 text: []const u8,
415 }; 415 };
416 416
417 /// One rule for both ends of a selection_reply: `.ok` carries valid UTF-8
418 /// within the cap, every refusal carries nothing at all. A writer that
419 /// checked less than the reader would mint a frame it could not read back.
420 fn checkSelectionText(status: SelectionStatus, text_value: []const u8) !void {
421 switch (status) {
422 .ok => {
423 if (text_value.len > selection_text_max or !std.unicode.utf8ValidateSlice(text_value))
424 return error.BadPayload;
425 },
426 .invalid, .too_large, .unavailable => {
427 if (text_value.len != 0) return error.BadPayload;
428 },
429 }
430 }
431
417 /// Validation completes before the first append, so `error.BadPayload` 432 /// Validation completes before the first append, so `error.BadPayload`
418 /// leaves a reused `out` unchanged. 433 /// leaves a reused `out` unchanged.
419 pub fn encodeSelectionReply( 434 pub fn encodeSelectionReply(
@@ -424,15 +439,7 @@ pub fn encodeSelectionReply(
424 history_rows: u32, 439 history_rows: u32,
425 text_value: []const u8, 440 text_value: []const u8,
426 ) !void { 441 ) !void {
427 switch (status) { 442 try checkSelectionText(status, text_value);
428 .ok => {
429 if (text_value.len > selection_text_max or !std.unicode.utf8ValidateSlice(text_value))
430 return error.BadPayload;
431 },
432 .invalid, .too_large, .unavailable => {
433 if (text_value.len != 0) return error.BadPayload;
434 },
435 }
436 443
437 var prefix: [selection_reply_prefix_len]u8 = undefined; 444 var prefix: [selection_reply_prefix_len]u8 = undefined;
438 std.mem.writeInt(u32, prefix[0..4], id, .little); 445 std.mem.writeInt(u32, prefix[0..4], id, .little);
@@ -446,15 +453,7 @@ pub fn decodeSelectionReply(payload: []const u8) !SelectionReply {
446 if (payload.len < selection_reply_prefix_len) return error.BadPayload; 453 if (payload.len < selection_reply_prefix_len) return error.BadPayload;
447 const status = try enumFromByte(SelectionStatus, payload[4]); 454 const status = try enumFromByte(SelectionStatus, payload[4]);
448 const text_value = payload[selection_reply_prefix_len..]; 455 const text_value = payload[selection_reply_prefix_len..];
449 switch (status) { 456 try checkSelectionText(status, text_value);
450 .ok => {
451 if (text_value.len > selection_text_max or !std.unicode.utf8ValidateSlice(text_value))
452 return error.BadPayload;
453 },
454 .invalid, .too_large, .unavailable => {
455 if (text_value.len != 0) return error.BadPayload;
456 },
457 }
458 return .{ 457 return .{
459 .id = std.mem.readInt(u32, payload[0..4], .little), 458 .id = std.mem.readInt(u32, payload[0..4], .little),
460 .status = status, 459 .status = status,