summaryrefslogtreecommitdiff
path: root/src/detached/wire.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/detached/wire.zig')
-rw-r--r--src/detached/wire.zig464
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.