b756779f
refactor: three client-side twins get one owner each
a73x 2026-08-29 10:01
Commit message
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, |