diff options
Diffstat (limited to 'src/detached/wire.zig')
| -rw-r--r-- | src/detached/wire.zig | 464 |
1 files changed, 207 insertions, 257 deletions
diff --git a/src/detached/wire.zig b/src/detached/wire.zig index f2030e26..a84c2a68 100644 --- a/src/detached/wire.zig +++ b/src/detached/wire.zig @@ -39,8 +39,23 @@ //! frontend must not have to have been built with the core's options — so it is //! ALWAYS on the wire and dropped on arrival by a build with nowhere to put it. //! -//! WHAT IS NOT HERE. The seam has twenty methods; this carries twelve of -//! them, and the eight it does not are named here with their reasons. +//! WHAT IS NOT HERE. The seam has twenty-one methods; this carries FIVE of +//! them — `push_present` as `frame`, `push_set_clipboard`, +//! `pull_read_clipboard`, `push_open_link` and `push_detach` — and the sixteen +//! it does not are named here with their reasons. The five are spelled out +//! because this arithmetic has now gone stale twice in one day, once when the +//! machine-local eight moved into the daemon and once when `detach` arrived, +//! and a count nobody can check against a list is a comment that rots quietly. +//! * The eight machine-local ones — `push_spawn`, `push_pty_write`, +//! `push_pty_resize`, `push_write_file`, `push_write_dump`, +//! `push_watch_file`, `push_watch_theme`, `push_dump_themes` — are +//! performed by the detached core ITSELF, through `host_io.zig`. A unix +//! socket means it is on the same machine, so there is no question of +//! whose disk or whose process table is meant, and a pane's shell has to +//! outlive the frontend that asked for it or a detached session is a +//! promise it cannot keep. The `ServerTag` doc below carries the whole of +//! that argument; this line exists so the count at the top of the file +//! agrees with it. //! * `pull_wait_input` IS the server's poll loop, not a message. //! * `push_poll_frame` and `push_post_present` carry no information. They are //! per-frame bookkeeping ticks, and `frame` already arrives exactly once @@ -114,13 +129,11 @@ const run_header = 4 + 2; const frame_head = 1 + 2 + 2 + 6 + 4; /// The longest legal payload, and therefore the length prefix a decoder will -/// accept before it refuses the stream. Three messages set it: +/// accept before it refuses the stream. Two messages set it: /// * a full frame of the largest grid, worst case one run per cell: /// 512*128 * (6 + 20) = 1.6 MiB. /// * one paste, which the tty frontend already caps at 4 MiB (tty.zig /// `max_paste_bytes`) on the grounds that anything larger is a mis-click. -/// * a `write_file`, whose bytes are a pane's whole text and are the only -/// genuinely open-ended payload here. /// 16 MiB is past every source file anyone edits in this editor and is still a /// buffer the receiving side can simply hold. A larger message is not sent and /// a larger prefix is not read. @@ -151,6 +164,26 @@ pub fn frameBound(cols: u16, rows: u16) usize { /// /// The numbers are the PROTOCOL's, grouped session/input rather than derived /// from `Event`'s declaration order, so reordering the union changes nothing. +/// +/// EVERY TAG HERE IS SOMETHING A HUMAN DID, and that is the whole set: a +/// handshake, a goodbye, and what a keyboard, a mouse, a trackpad or a window +/// manager produces. Six numbers are missing from the input run — 0x13..0x17 +/// and 0x1e — and the gaps are left rather than tidied away, because +/// renumbering is a change every deployed frontend feels. They were `output`, +/// `eof`, `lsp_resp`, `pipe_resp`, `file_changed` and `tick`: the +/// MACHINE-LOCAL host's own reports, which stopped being a frontend's business +/// when the daemon took the disk and the process table (host_io.zig, +/// file_watch.zig). No frontend ever produced one — tty.zig's attached loop +/// swallowed them by name and gui.zig never handed `Input.post` one — and +/// leaving them DECODABLE was not merely dead weight: server.zig's `apply` +/// routes any decoded non-resize event straight into `core.update`, so an +/// attached peer could forge a pane's output, forge an `eof` for a shell that +/// was still running (and unlike the daemon's own `paneEof` the wire path never +/// called `closePty`, so the master stayed open and the shell was orphaned for +/// the life of the session), or replace a pane's text with bytes the next +/// `Save` would write to disk. client.zig's header says a machine-local effect +/// cannot return to the wire; deleting these is what makes that true in BOTH +/// directions instead of only core -> frontend. pub const ClientTag = enum(u8) { hello = 0x01, bye = 0x02, @@ -158,40 +191,46 @@ pub const ClientTag = enum(u8) { key = 0x10, mouse = 0x11, resize = 0x12, - output = 0x13, - eof = 0x14, - lsp_resp = 0x15, - pipe_resp = 0x16, - file_changed = 0x17, paste = 0x18, command = 0x19, pdf_scroll = 0x1a, pinch = 0x1b, touch_scroll = 0x1c, pointer_leave = 0x1d, - tick = 0x1e, }; -/// Core -> frontend. 0x01..0x0f is the session, 0x10.. is one `push_` method +/// Core -> frontend. 0x01..0x0f is the session; 0x10.. is one `push_` method /// each, in `Host.VTable`'s own order so the two lists can be read side by /// side. +/// +/// There are only THREE of those left, and which three is the whole design. +/// The daemon performs every effect that needs a disk or a process table +/// itself (see `host_io.zig`): a unix socket means it is on the same machine, +/// so there is no question of whose disk is meant, and a pane's shell has to +/// outlive the frontend that asked for it or a detached session is a promise +/// it cannot keep. What is left on the wire is what a process nobody is +/// looking at genuinely cannot do — put something on THIS human's clipboard, +/// read it back, and open a link in front of the person who clicked it. +/// +/// `detach` is in the SESSION range and not among those three on purpose: it is +/// not an effect the core wants performed, it is the session telling one +/// frontend that it is done. `quit` is its sibling — same shape, opposite +/// meaning about whether anything survives. pub const ServerTag = enum(u8) { welcome = 0x01, refuse = 0x02, frame = 0x03, quit = 0x04, + /// One frontend is done, and the session is NOT over. The `Detach` word, + /// routed back to the frontend whose keystroke ran it (server.zig ORIGIN, + /// ELSE PRIMARY, the same rule `read_clipboard` takes and for the same + /// reason). Every other frontend, the core and the pane shells are + /// untouched, so leaving is success rather than a failure to report. + detach = 0x05, - spawn = 0x10, - pty_write = 0x11, - pty_resize = 0x12, - write_file = 0x13, - write_dump = 0x14, - watch_file = 0x15, - watch_theme = 0x16, - dump_themes = 0x17, - set_clipboard = 0x18, - read_clipboard = 0x19, - open_link = 0x1a, + set_clipboard = 0x10, + read_clipboard = 0x11, + open_link = 0x12, }; /// Why the server hung up on a connect. Sent as a `refuse` and followed by a @@ -254,6 +293,23 @@ pub const Hello = struct { rows: u16, }; +/// `Hello.version` alone, read out of the still-undecoded payload. +/// +/// Separate from `decodeClient` because of the guarantee the fixed offset above +/// exists to give: a decoder that validates `cols` and `rows` and then refuses +/// trailing bytes can never deliver it. A v2 hello with one more field would be +/// closed as a malformed message, and the `Refusal.version` byte a frontend +/// needs in order to say something true would never be sent — which is exactly +/// the case the field was put at offset zero for. So the version is asked for +/// first, on its own, before any of the layout that may have moved. +/// +/// An error rather than a zero on a payload shorter than two bytes, so a +/// truncated hello stays diagnosable too instead of reading as version 0. +pub fn helloVersion(payload: []const u8) Error!u16 { + var r: Reader = .init(payload); + return r.getU16(); +} + pub const Welcome = struct { version: u16 = version, /// Which client slot this connection got. Carried because it is what the @@ -324,28 +380,14 @@ pub const ServerMsg = union(enum) { /// The session is over. Sent before the listener closes so a frontend can /// exit rather than report a broken pipe. quit, + /// One frontend is done, and the session is NOT over — see `ServerTag`. + detach, - spawn: struct { pane: u8, cwd: []const u8 }, - pty_write: struct { pane: u8, bytes: []const u8 }, - pty_resize: struct { pane: u8, cols: u16, rows: u16 }, - write_file: struct { pane: u8, path: []const u8, bytes: []const u8 }, - write_dump: []const u8, - watch_file: struct { pane: u8, path: []const u8, on: bool }, - watch_theme: struct { generation: u32, on: bool }, - dump_themes: struct { pane: u8 }, set_clipboard: []const u8, read_clipboard, open_link: []const u8, }; -/// The one thing a decoder cannot put in a byte buffer: `pipe_resp.outputs` is -/// a `[]const []const u8`, so the outer array needs somewhere to live. Sized -/// from the core's own ceiling on selections (`MAX_SELS`), which is what bounds -/// the count a legitimate `pipe_resp` can carry. -pub const Scratch = struct { - outputs: [pardes.MAX_SELS][]const u8 = undefined, -}; - // --------------------------------------------------------------------------- // primitives // --------------------------------------------------------------------------- @@ -796,10 +838,16 @@ fn sendCell(cells: []const pardes.Cell, prev: []const pardes.Cell, full: bool, i fn clientTag(msg: ClientMsg) ClientTag { return switch (msg) { .event => |ev| switch (ev) { - // Not on the wire, and not an omission: see the module header. - // The acme mount lives with the core, so this event is raised in - // the same process that answers it and never crosses a socket. - .fs_req => unreachable, + // SEVEN `Event`s a frontend cannot produce, so no `ClientTag` + // exists for them and this arm is where the compiler says so. + // `fs_req` is the acme mount, raised in the same process that + // answers it. The other six are the machine-local host's own + // reports — a pty's output and its EOF, a language or pipe worker's + // answer, a watched file's new bytes, an animation tick — and after + // the daemon took the disk and the process table every one of them + // is raised by the process that already holds the core. See + // `ClientTag` for what putting them back would let a peer forge. + .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .fs_req => unreachable, inline else => |_, t| @field(ClientTag, @tagName(t)), }, inline else => |_, t| @field(ClientTag, @tagName(t)), @@ -849,26 +897,6 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 { try w.putU16(if (comptime has_cell_pixels) rs.cell_pixels.w else 8); try w.putU16(if (comptime has_cell_pixels) rs.cell_pixels.h else 16); }, - .output => |o| { - try w.putByte(o.pane); - try w.putSlice32(o.bytes); - }, - .eof => |e| try w.putByte(e.pane), - .lsp_resp => |l| { - try w.putU32(l.id); - try w.putSlice32(l.rows); - }, - .pipe_resp => |p| { - try w.putU32(p.id); - try w.putBool(p.success); - if (p.outputs.len > pardes.MAX_SELS) return error.Overlong; - try w.putU16(@intCast(p.outputs.len)); - for (p.outputs) |o| try w.putSlice32(o); - }, - .file_changed => |f| { - try w.putByte(f.pane); - try w.putSlice32(f.bytes); - }, .paste => |b| try w.putSlice32(b), .command => |line| try w.putSlice16(line), .pdf_scroll => |s| { @@ -877,15 +905,16 @@ pub fn encodeClient(out: []u8, msg: ClientMsg) Error![]const u8 { }, .pinch => |v| try w.putF32(v), .touch_scroll => |v| try w.putF32(v), - .pointer_leave, .tick => {}, - .fs_req => unreachable, + .pointer_leave => {}, + // See `clientTag`: no tag, so nothing to encode. + .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .fs_req => unreachable, }, } try finishMessage(&w, at); return w.written(); } -pub fn decodeClient(tag: u8, payload: []const u8, scratch: *Scratch) Error!ClientMsg { +pub fn decodeClient(tag: u8, payload: []const u8) Error!ClientMsg { var r: Reader = .init(payload); const msg: ClientMsg = switch (std.enums.fromInt(ClientTag, tag) orelse return error.BadTag) { .hello => .{ .hello = .{ @@ -928,29 +957,12 @@ pub fn decodeClient(tag: u8, payload: []const u8, scratch: *Scratch) Error!Clien if (comptime has_cell_pixels) ev.resize.cell_pixels = .{ .w = px_w, .h = px_h }; break :blk .{ .event = ev }; }, - .output => .{ .event = .{ .output = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } } }, - .eof => .{ .event = .{ .eof = .{ .pane = try r.getPane() } } }, - .lsp_resp => .{ .event = .{ .lsp_resp = .{ .id = try r.getU32(), .rows = try r.getSlice32() } } }, - .pipe_resp => blk: { - const id = try r.getU32(); - const success = try r.getBool(); - const n = try r.getU16(); - if (n > scratch.outputs.len) return error.Overlong; - for (scratch.outputs[0..n]) |*o| o.* = try r.getSlice32(); - break :blk .{ .event = .{ .pipe_resp = .{ - .id = id, - .success = success, - .outputs = scratch.outputs[0..n], - } } }; - }, - .file_changed => .{ .event = .{ .file_changed = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } } }, .paste => .{ .event = .{ .paste = try r.getSlice32() } }, .command => .{ .event = .{ .command = try r.getSlice16() } }, .pdf_scroll => .{ .event = .{ .pdf_scroll = .{ .pane = try r.getPane(), .delta_pixels = try r.getF32() } } }, .pinch => .{ .event = .{ .pinch = try r.getF32() } }, .touch_scroll => .{ .event = .{ .touch_scroll = try r.getF32() } }, .pointer_leave => .{ .event = .pointer_leave }, - .tick => .{ .event = .tick }, }; try r.end(); return msg; @@ -984,36 +996,7 @@ pub fn encodeServer(out: []u8, msg: ServerMsg) Error![]const u8 { // encodeFrame directly and this arm exists so the switch stays // exhaustive over ServerMsg. .frame => return error.BadValue, - .quit => {}, - .spawn => |s| { - try w.putByte(s.pane); - try w.putSlice16(s.cwd); - }, - .pty_write => |p| { - try w.putByte(p.pane); - try w.putSlice32(p.bytes); - }, - .pty_resize => |p| { - try w.putByte(p.pane); - try w.putU16(p.cols); - try w.putU16(p.rows); - }, - .write_file => |f| { - try w.putByte(f.pane); - try w.putSlice16(f.path); - try w.putSlice32(f.bytes); - }, - .write_dump => |b| try w.putSlice32(b), - .watch_file => |v| { - try w.putByte(v.pane); - try w.putSlice16(v.path); - try w.putBool(v.on); - }, - .watch_theme => |t| { - try w.putU32(t.generation); - try w.putBool(t.on); - }, - .dump_themes => |d| try w.putByte(d.pane), + .quit, .detach => {}, .set_clipboard => |t| try w.putSlice32(t), .read_clipboard => {}, .open_link => |u| try w.putSlice16(u), @@ -1033,14 +1016,9 @@ const msg_slack = header_len + 32; /// once instead of guessing and retrying. pub fn serverBound(msg: ServerMsg) usize { return msg_slack + switch (msg) { - .welcome, .refuse, .quit, .pty_resize, .watch_theme, .dump_themes, .read_clipboard => 0, + .welcome, .refuse, .quit, .detach, .read_clipboard => 0, // A frame is bounded by its grid, not by this: see `frameBound`. .frame => |f| frameBound(f.cols, f.rows), - .spawn => |s| s.cwd.len, - .pty_write => |p| p.bytes.len, - .write_file => |f| f.path.len + f.bytes.len, - .write_dump => |b| b.len, - .watch_file => |v| v.path.len, .set_clipboard => |t| t.len, .open_link => |u| u.len, }; @@ -1051,21 +1029,12 @@ pub fn clientBound(msg: ClientMsg) usize { return msg_slack + switch (msg) { .hello, .bye => 0, .event => |ev| switch (ev) { - .mouse, .resize, .eof, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave, .tick => 0, + .mouse, .resize, .pdf_scroll, .pinch, .touch_scroll, .pointer_leave => 0, .key => |k| k.text.len, - .output => |o| o.bytes.len, - .lsp_resp => |l| l.rows.len, - .pipe_resp => |p| blk: { - // Each output carries its own u32 prefix, so the count is part - // of the bound and not just the bytes. - var total: usize = p.outputs.len * 4; - for (p.outputs) |o| total += o.len; - break :blk total; - }, - .file_changed => |f| f.bytes.len, .paste => |b| b.len, .command => |line| line.len, - .fs_req => unreachable, + // See `clientTag`: not on this wire in this direction. + .output, .eof, .lsp_resp, .pipe_resp, .file_changed, .tick, .fs_req => unreachable, }, }; } @@ -1103,26 +1072,7 @@ pub fn decodeServer(tag: u8, payload: []const u8) Error!ServerMsg { } }; }, .quit => .quit, - .spawn => .{ .spawn = .{ .pane = try r.getPane(), .cwd = try r.getSlice16() } }, - .pty_write => .{ .pty_write = .{ .pane = try r.getPane(), .bytes = try r.getSlice32() } }, - .pty_resize => .{ .pty_resize = .{ - .pane = try r.getPane(), - .cols = try r.getU16(), - .rows = try r.getU16(), - } }, - .write_file => .{ .write_file = .{ - .pane = try r.getPane(), - .path = try r.getSlice16(), - .bytes = try r.getSlice32(), - } }, - .write_dump => .{ .write_dump = try r.getSlice32() }, - .watch_file => .{ .watch_file = .{ - .pane = try r.getPane(), - .path = try r.getSlice16(), - .on = try r.getBool(), - } }, - .watch_theme => .{ .watch_theme = .{ .generation = try r.getU32(), .on = try r.getBool() } }, - .dump_themes => .{ .dump_themes = .{ .pane = try r.getPane() } }, + .detach => .detach, .set_clipboard => .{ .set_clipboard = try r.getSlice32() }, .read_clipboard => .read_clipboard, .open_link => .{ .open_link = try r.getSlice16() }, @@ -1144,11 +1094,11 @@ const testing = std.testing; /// Round-trip one frontend -> core message through the framing too, so a /// length prefix that disagrees with the payload cannot pass. -fn roundClient(buf: []u8, msg: ClientMsg, scratch: *Scratch) !ClientMsg { +fn roundClient(buf: []u8, msg: ClientMsg) !ClientMsg { const bytes = try encodeClient(buf, msg); const f = (try framed(bytes)).?; try testing.expectEqual(bytes.len, f.total); - return decodeClient(f.tag, f.payload, scratch); + return decodeClient(f.tag, f.payload); } fn roundServer(buf: []u8, msg: ServerMsg) !ServerMsg { @@ -1160,26 +1110,25 @@ fn roundServer(buf: []u8, msg: ServerMsg) !ServerMsg { test "detached wire: every Event variant round-trips" { var buf: [4096]u8 = undefined; - var scratch: Scratch = .{}; // The tag space is the protocol's own, so assert the numbers themselves: // a renumbering here breaks every deployed frontend and must be a diff // somebody reads, not a silent change. try testing.expectEqual(@as(u8, 0x01), @intFromEnum(ClientTag.hello)); try testing.expectEqual(@as(u8, 0x10), @intFromEnum(ClientTag.key)); - try testing.expectEqual(@as(u8, 0x1e), @intFromEnum(ClientTag.tick)); + try testing.expectEqual(@as(u8, 0x1d), @intFromEnum(ClientTag.pointer_leave)); { - const got = try roundClient(&buf, .{ .hello = .{ .cols = 80, .rows = 24 } }, &scratch); + const got = try roundClient(&buf, .{ .hello = .{ .cols = 80, .rows = 24 } }); try testing.expectEqual(version, got.hello.version); try testing.expectEqual(@as(u16, 80), got.hello.cols); try testing.expectEqual(@as(u16, 24), got.hello.rows); } - try testing.expectEqual(ClientMsg.bye, try roundClient(&buf, .bye, &scratch)); + try testing.expectEqual(ClientMsg.bye, try roundClient(&buf, .bye)); { const key: pardes.Key = .{ .cp = pardes.Key.page_down, .text = "ü", .ctrl = true, .alt = false, .shift = true }; - const got = (try roundClient(&buf, .{ .event = .{ .key = key } }, &scratch)).event.key; + const got = (try roundClient(&buf, .{ .event = .{ .key = key } })).event.key; try testing.expectEqual(key.cp, got.cp); try testing.expectEqualStrings(key.text, got.text); try testing.expectEqual(key.ctrl, got.ctrl); @@ -1192,7 +1141,7 @@ test "detached wire: every Event variant round-trips" { for (std.enums.values(pardes.Mouse.Button)) |button| { for (std.enums.values(pardes.Mouse.Kind)) |kind| { const m: pardes.Mouse = .{ .button = button, .kind = kind, .col = 4200, .row = 7, .ctrl = true }; - const got = (try roundClient(&buf, .{ .event = .{ .mouse = m } }, &scratch)).event.mouse; + const got = (try roundClient(&buf, .{ .event = .{ .mouse = m } })).event.mouse; try testing.expectEqual(m.button, got.button); try testing.expectEqual(m.kind, got.kind); try testing.expectEqual(m.col, got.col); @@ -1202,7 +1151,7 @@ test "detached wire: every Event variant round-trips" { } } { - const got = (try roundClient(&buf, .{ .event = .{ .resize = .{ .cols = 56, .rows = 14 } } }, &scratch)).event.resize; + const got = (try roundClient(&buf, .{ .event = .{ .resize = .{ .cols = 56, .rows = 14 } } })).event.resize; try testing.expectEqual(@as(u16, 56), got.cols); try testing.expectEqual(@as(u16, 14), got.rows); // Pixels travel whether or not this build has them; where it does, the @@ -1212,48 +1161,18 @@ test "detached wire: every Event variant round-trips" { try testing.expectEqual(@as(u16, 16), got.cell_pixels.h); } } + try testing.expectEqualStrings("clip", (try roundClient(&buf, .{ .event = .{ .paste = "clip" } })).event.paste); + try testing.expectEqualStrings("Look /x", (try roundClient(&buf, .{ .event = .{ .command = "Look /x" } })).event.command); { - const got = (try roundClient(&buf, .{ .event = .{ .output = .{ .pane = 3, .bytes = "hi\x00there" } } }, &scratch)).event.output; - try testing.expectEqual(@as(u8, 3), got.pane); - try testing.expectEqualStrings("hi\x00there", got.bytes); - } - try testing.expectEqual(@as(u8, 15), (try roundClient(&buf, .{ .event = .{ .eof = .{ .pane = 15 } } }, &scratch)).event.eof.pane); - { - const got = (try roundClient(&buf, .{ .event = .{ .lsp_resp = .{ .id = 0xdeadbeef, .rows = "a:1:2-3 x" } } }, &scratch)).event.lsp_resp; - try testing.expectEqual(@as(u32, 0xdeadbeef), got.id); - try testing.expectEqualStrings("a:1:2-3 x", got.rows); - } - { - // Including an EMPTY output, which is what a filter that consumed a - // selection and printed nothing returns. - const outputs: []const []const u8 = &.{ "AA\n", "", "cc" }; - const got = (try roundClient(&buf, .{ .event = .{ .pipe_resp = .{ .id = 9, .success = true, .outputs = outputs } } }, &scratch)).event.pipe_resp; - try testing.expectEqual(@as(u32, 9), got.id); - try testing.expect(got.success); - try testing.expectEqual(@as(usize, 3), got.outputs.len); - for (outputs, got.outputs) |want, have| try testing.expectEqualStrings(want, have); - } - { - const got = (try roundClient(&buf, .{ .event = .{ .file_changed = .{ .pane = 0, .bytes = "" } } }, &scratch)).event.file_changed; - try testing.expectEqual(@as(u8, 0), got.pane); - try testing.expectEqualStrings("", got.bytes); - } - try testing.expectEqualStrings("clip", (try roundClient(&buf, .{ .event = .{ .paste = "clip" } }, &scratch)).event.paste); - try testing.expectEqualStrings("Look /x", (try roundClient(&buf, .{ .event = .{ .command = "Look /x" } }, &scratch)).event.command); - { - const got = (try roundClient(&buf, .{ .event = .{ .pdf_scroll = .{ .pane = 2, .delta_pixels = -12.5 } } }, &scratch)).event.pdf_scroll; + const got = (try roundClient(&buf, .{ .event = .{ .pdf_scroll = .{ .pane = 2, .delta_pixels = -12.5 } } })).event.pdf_scroll; try testing.expectEqual(@as(u8, 2), got.pane); try testing.expectEqual(@as(f32, -12.5), got.delta_pixels); } - try testing.expectEqual(@as(f32, 1.25), (try roundClient(&buf, .{ .event = .{ .pinch = 1.25 } }, &scratch)).event.pinch); - try testing.expectEqual(@as(f32, -0.75), (try roundClient(&buf, .{ .event = .{ .touch_scroll = -0.75 } }, &scratch)).event.touch_scroll); + try testing.expectEqual(@as(f32, 1.25), (try roundClient(&buf, .{ .event = .{ .pinch = 1.25 } })).event.pinch); + try testing.expectEqual(@as(f32, -0.75), (try roundClient(&buf, .{ .event = .{ .touch_scroll = -0.75 } })).event.touch_scroll); try testing.expectEqual( std.meta.Tag(pardes.Event).pointer_leave, - (try roundClient(&buf, .{ .event = .pointer_leave }, &scratch)).event, - ); - try testing.expectEqual( - std.meta.Tag(pardes.Event).tick, - (try roundClient(&buf, .{ .event = .tick }, &scratch)).event, + (try roundClient(&buf, .{ .event = .pointer_leave })).event, ); } @@ -1270,44 +1189,91 @@ test "detached wire: every server message round-trips" { for (std.enums.values(Refusal)) |why| try testing.expectEqual(why, (try roundServer(&buf, .{ .refuse = why })).refuse); try testing.expectEqual(ServerMsg.quit, try roundServer(&buf, .quit)); - { - const got = (try roundServer(&buf, .{ .spawn = .{ .pane = 1, .cwd = "/home/x" } })).spawn; - try testing.expectEqual(@as(u8, 1), got.pane); - try testing.expectEqualStrings("/home/x", got.cwd); - } - { - const got = (try roundServer(&buf, .{ .pty_write = .{ .pane = 1, .bytes = "ls\r" } })).pty_write; - try testing.expectEqual(@as(u8, 1), got.pane); - try testing.expectEqualStrings("ls\r", got.bytes); - } - { - const got = (try roundServer(&buf, .{ .pty_resize = .{ .pane = 1, .cols = 80, .rows = 24 } })).pty_resize; - try testing.expectEqual(@as(u16, 80), got.cols); - try testing.expectEqual(@as(u16, 24), got.rows); - } - { - const got = (try roundServer(&buf, .{ .write_file = .{ .pane = 4, .path = "/tmp/a", .bytes = "body\n" } })).write_file; - try testing.expectEqual(@as(u8, 4), got.pane); - try testing.expectEqualStrings("/tmp/a", got.path); - try testing.expectEqualStrings("body\n", got.bytes); - } - try testing.expectEqualStrings(".{}", (try roundServer(&buf, .{ .write_dump = ".{}" })).write_dump); - { - const got = (try roundServer(&buf, .{ .watch_file = .{ .pane = 0, .path = "/tmp/b", .on = true } })).watch_file; - try testing.expectEqualStrings("/tmp/b", got.path); - try testing.expect(got.on); - } - { - const got = (try roundServer(&buf, .{ .watch_theme = .{ .generation = 7, .on = false } })).watch_theme; - try testing.expectEqual(@as(u32, 7), got.generation); - try testing.expect(!got.on); - } - try testing.expectEqual(@as(u8, 5), (try roundServer(&buf, .{ .dump_themes = .{ .pane = 5 } })).dump_themes.pane); + try testing.expectEqual(ServerMsg.detach, try roundServer(&buf, .detach)); try testing.expectEqualStrings("yank", (try roundServer(&buf, .{ .set_clipboard = "yank" })).set_clipboard); try testing.expectEqual(ServerMsg.read_clipboard, try roundServer(&buf, .read_clipboard)); try testing.expectEqualStrings("https://x", (try roundServer(&buf, .{ .open_link = "https://x" })).open_link); } +test "detached wire: only the display's own effects are on the wire" { + // Two guards, because the mistake has two directions. The exhaustive + // switch fails to COMPILE if a machine-local effect is added back, which + // is the direction that matters: it would hand a frontend work that must + // outlive it. The count fails if one of the three is dropped, which would + // silently leave a clipboard or a link unanswered on every frontend. + try testing.expectEqual(@as(usize, 8), std.enums.values(ServerTag).len); + for (std.enums.values(ServerTag)) |t| switch (t) { + .welcome, .refuse, .frame, .quit, .detach, .set_clipboard, .read_clipboard, .open_link => {}, + }; +} + +test "detached wire: a session is never told to do a frontend's remembering" { + // The mirror of the test above, and of client.zig's "a frontend is never + // asked to fork, write, or watch". That one pins what may reach a FRONTEND; + // this one pins what may reach the SESSION, and until the six tags below it + // names were deleted the protocol was asymmetric: server -> client carried + // only what a display can do, while client -> server still carried the + // machine-local host's own reports, which server.zig's `apply` hands + // straight to `core.update`. + // + // Adding a name here is meant to be an ARGUMENT, not a formality, and the + // bar is one sentence: A HUMAN DID IT. A keystroke, a click, a pinch, a + // window resized, a paste, a command line executed, a pointer leaving the + // window — plus the two session words that say who is speaking. A pty's + // output, a worker's answer, a watched file's new bytes and an animation + // tick all fail that bar the same way: nobody did them, a machine reported + // them, and the machine that reports them is the one already holding the + // core. + const allowed = [_][]const u8{ + // Who this connection is, and that it is finished. + "hello", "bye", + // ...and everything a person can do to a window. + "key", "mouse", + "resize", "paste", + "command", "pdf_scroll", + "pinch", "touch_scroll", + "pointer_leave", + }; + + // One: the tag set is exactly that, named rather than counted, so + // re-adding `output` fails with the name in the failure. + inline for (std.enums.values(ClientTag)) |t| { + for (allowed) |ok| { + if (std.mem.eql(u8, @tagName(t), ok)) break; + } else { + std.debug.print("ClientTag.{s} is not something a human did\n", .{@tagName(t)}); + return error.MachineLocalReportOnTheWire; + } + } + try testing.expectEqual(allowed.len, std.enums.values(ClientTag).len); + + // Two: and no tag BYTE outside them decodes either — the check above is + // about this build's enum, this one is about the bytes on the socket. The + // six deleted numbers (0x13..0x17, 0x1e) are in the 250 that must answer + // `BadTag`, so a frontend built before this change cannot forge a pane's + // output into a session built after it. + var accepted: usize = 0; + for (0..256) |i| { + const tag: u8 = @intCast(i); + // Empty payloads on purpose: what is asked is whether the TAG is known. + // A known one fails later (`Truncated`) or succeeds, never with + // `BadTag`. + if (decodeClient(tag, &.{})) |_| accepted += 1 else |err| switch (err) { + error.BadTag => continue, + else => accepted += 1, + } + const named = for (allowed) |ok| { + const want = std.meta.stringToEnum(ClientTag, ok).?; + if (@intFromEnum(want) == tag) break true; + } else false; + if (!named) { + std.debug.print("tag 0x{x:0>2} is decodable by a session and is not something a human did\n", .{tag}); + return error.MachineLocalReportOnTheWire; + } + } + try testing.expectEqual(allowed.len, accepted); +} + /// A grid with something in every corner: a default cell, a plain ASCII cell, /// an indexed pair, an rgb pair with every attribute on, and a multi-byte /// grapheme — the five shapes `putCell` branches on. @@ -1481,7 +1447,6 @@ test "detached wire: a run is coalesced across a gap only when that is cheaper" test "detached wire: a truncated frame is refused at every length" { var buf: [4096]u8 = undefined; - var scratch: Scratch = .{}; // Every prefix of a real message. The framing must say "not yet" for the // ones that are short, and the decoder must say "truncated" for a payload @@ -1494,22 +1459,21 @@ test "detached wire: a truncated frame is refused at every length" { // ...and the same bytes handed to the decoder with the header's length // left claiming the whole message, which is how a decoder is walked // off the end of its buffer. - try testing.expectError(error.Truncated, decodeClient(copy[0], copy[header_len..cut], &scratch)); + try testing.expectError(error.Truncated, decodeClient(copy[0], copy[header_len..cut])); } // Shorter than the header itself is not yet a message at all. for (0..header_len + 1) |cut| try testing.expectEqual(@as(?Framed, null), try framed(copy[0..cut])); // A payload with bytes LEFT OVER is refused too: it is not this message. - try testing.expectError(error.Trailing, decodeClient(@intFromEnum(ClientTag.tick), "x", &scratch)); + try testing.expectError(error.Trailing, decodeClient(@intFromEnum(ClientTag.pointer_leave), "x")); try testing.expectError(error.Trailing, decodeServer(@intFromEnum(ServerTag.quit), "x")); } test "detached wire: an unknown tag is refused, never guessed" { - var scratch: Scratch = .{}; // 0x00 and 0xff have never been assigned, and 0x0f sits in the gap between // the session tags and the input tags. All three are the same answer. for ([_]u8{ 0x00, 0x0f, 0x1f, 0xff }) |tag| { - try testing.expectError(error.BadTag, decodeClient(tag, "", &scratch)); + try testing.expectError(error.BadTag, decodeClient(tag, "")); try testing.expectError(error.BadTag, decodeServer(tag, "")); } // A tag NESTED in a payload gets the same treatment: a color, an @@ -1518,7 +1482,6 @@ test "detached wire: an unknown tag is refused, never guessed" { try testing.expectError(error.BadTag, decodeClient( @intFromEnum(ClientTag.mouse), &.{ 0x09, 0x00, 0, 0, 0, 0, 0 }, - &scratch, )); } @@ -1536,14 +1499,13 @@ test "detached wire: an over-long length prefix is refused before it is believed // An INNER length prefix, past the payload it sits in but inside the // protocol's cap — the one an outer-frame check cannot catch. - var scratch: Scratch = .{}; var paste: [8]u8 = undefined; std.mem.writeInt(u32, paste[0..4], 4096, .little); @memcpy(paste[4..8], "abcd"); - try testing.expectError(error.Truncated, decodeClient(@intFromEnum(ClientTag.paste), &paste, &scratch)); + try testing.expectError(error.Truncated, decodeClient(@intFromEnum(ClientTag.paste), &paste)); // ...and past the cap, which is refused rather than read. std.mem.writeInt(u32, paste[0..4], max_payload + 1, .little); - try testing.expectError(error.Overlong, decodeClient(@intFromEnum(ClientTag.paste), &paste, &scratch)); + try testing.expectError(error.Overlong, decodeClient(@intFromEnum(ClientTag.paste), &paste)); } test "detached wire: a frame that lies about its runs cannot walk out of the grid" { @@ -1664,56 +1626,44 @@ test "detached wire: a cursor outside the grid is refused, not painted" { } test "detached wire: values a field cannot mean are refused" { - var scratch: Scratch = .{}; - // A bool is 0 or 1. `2` used to be "true" in every hand-written codec that // ever silently accepted a corrupt stream. try testing.expectError(error.BadValue, decodeClient( @intFromEnum(ClientTag.key), &.{ 'a', 0, 0, 0, 0, 0, 2, 0, 0 }, - &scratch, )); // A codepoint past Unicode's last: @intCast into Key.cp's u21 would panic. try testing.expectError(error.BadValue, decodeClient( @intFromEnum(ClientTag.key), &.{ 0x00, 0x00, 0x11, 0x00, 0, 0, 0, 0, 0 }, - &scratch, )); - // A pane the core cannot index. + // A pane the core cannot index. `pdf_scroll` reads its pane byte before the + // f32 that follows, so a one-byte payload reaches the check this is about + // rather than tripping `Truncated` first. try testing.expectError(error.BadValue, decodeClient( - @intFromEnum(ClientTag.eof), + @intFromEnum(ClientTag.pdf_scroll), &.{pardes.MAX_PANES}, - &scratch, )); // A zero-column grid would collapse a shared session; an over-wide one is // past what this protocol carries. try testing.expectError(error.BadValue, decodeClient( @intFromEnum(ClientTag.hello), &.{ 1, 0, 0, 0, 24, 0 }, - &scratch, )); { var hello: [6]u8 = undefined; std.mem.writeInt(u16, hello[0..2], version, .little); std.mem.writeInt(u16, hello[2..4], max_cols + 1, .little); std.mem.writeInt(u16, hello[4..6], 24, .little); - try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.hello), &hello, &scratch)); + try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.hello), &hello)); } // A NaN scroll distance. `pinch` multiplies into a zoom the pane keeps. { var pinch: [4]u8 = undefined; std.mem.writeInt(u32, &pinch, @bitCast(std.math.nan(f32)), .little); - try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.pinch), &pinch, &scratch)); + try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.pinch), &pinch)); std.mem.writeInt(u32, &pinch, @bitCast(std.math.inf(f32)), .little); - try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.touch_scroll), &pinch, &scratch)); - } - // More pipe outputs than the core has selections to produce them. - { - var head: [7]u8 = undefined; - std.mem.writeInt(u32, head[0..4], 1, .little); - head[4] = 1; - std.mem.writeInt(u16, head[5..7], pardes.MAX_SELS + 1, .little); - try testing.expectError(error.Overlong, decodeClient(@intFromEnum(ClientTag.pipe_resp), &head, &scratch)); + try testing.expectError(error.BadValue, decodeClient(@intFromEnum(ClientTag.touch_scroll), &pinch)); } // A cell with the reserved attribute bit set, and one with an impossible // grapheme length. Both are bytes this protocol has no meaning for. |
