diff options
Diffstat (limited to 'src/detached/client.zig')
| -rw-r--r-- | src/detached/client.zig | 228 |
1 files changed, 164 insertions, 64 deletions
diff --git a/src/detached/client.zig b/src/detached/client.zig index fc39164b..0350ecf7 100644 --- a/src/detached/client.zig +++ b/src/detached/client.zig @@ -40,7 +40,7 @@ //! DISPLAY: a daemon nobody is looking at has no clipboard and no browser. //! The answer to `read_clipboard` is not a reply message: it is an ordinary //! `Event.paste` sent back through `send`, which is the same asynchronous -//! shape `pull_read_clipboard` already has in-process. +//! shape `read_clipboard` already has in-process. //! * `refuse` is followed by the session closing the connection, and `quit` //! means the session itself has ended. //! The switch in `next` is exhaustive over that set on purpose: putting a @@ -156,7 +156,10 @@ pub const Client = struct { server.setNonblock(fd); var c: Client = .{ .gpa = gpa, .fd = fd }; errdefer c.deinit(); - try c.send(.{ .hello = .{ .cols = cols, .rows = rows } }); + try c.send(.{ .hello = .{ + .cols = @min(cols, wire.max_cols), + .rows = @min(rows, wire.max_rows), + } }); return c; } @@ -214,8 +217,33 @@ pub const Client = struct { /// Tell the session this frontend's window changed. Not a promise about the /// next frame: with other frontends attached the session grid is the /// smallest common one. + /// + /// Clamped, like the hello in `open`: a window past `max_cols`/`max_rows` + /// is a geometry the protocol cannot carry, and the decoder on the far end + /// answers one with `BadValue` — which `apply` turns into `close(.protocol)` + /// with no `refuse` behind it, so the frontend was told only that the + /// session "hung up on the connect". A 4K display at a small font is + /// already past `max_rows`, which made an ordinary big screen unable to + /// attach at all. Asking for the largest grid the wire carries is what this + /// file's GEOMETRY note already promises a frontend gets: the session is + /// drawn at ITS size wherever the window is bigger, exactly as it is when + /// another frontend is the smaller one. + /// + /// A ZERO is dropped rather than clamped, and that asymmetry is the whole + /// point of `getCols` refusing zero in the first place: the session grid is + /// the smallest common one, so a frontend that reported 1 would collapse + /// every other frontend to a single cell. `TIOCGWINSZ` answers 0x0 while a + /// terminal is being torn down and tty.zig forwards a `winsize` verbatim + /// (its ATTACH path already refuses a zero one, its resize path did not), + /// so this is reachable without a hostile peer — and it reached the session + /// as the same mute `close(.protocol)` the oversize geometry did. Keeping + /// the last real window is what a momentary zero means. pub fn resize(c: *Client, cols: u16, rows: u16) (Error || wire.Error)!void { - return c.send(.{ .event = .{ .resize = .{ .cols = cols, .rows = rows } } }); + if (cols == 0 or rows == 0) return; + return c.send(.{ .event = .{ .resize = .{ + .cols = @min(cols, wire.max_cols), + .rows = @min(rows, wire.max_rows), + } } }); } /// Wait up to `timeout_ms` for the session to say something, and push @@ -453,26 +481,7 @@ pub fn resolve(buf: *[server.path_max]u8, requested: []const u8) Resolved { return .{ .name = buf[0..len] }; } -/// How long a frontend's attached loop waits on the socket before it goes back -/// to whatever else it owns. It lives HERE, beside the `wait` it parameterises, -/// because both frontends need it and both had defined it for themselves — -/// which is how a measured number drifts from the thing it was measured -/// against. -/// -/// A frontend cannot hand `poll(2)` one descriptor for the session and one for -/// its own input: vaxis delivers the terminal's events on a reader thread into -/// a mutex/condvar queue, and SDL has its own pump, so neither has a -/// descriptor. `wait` takes a timeout for exactly that reason. -/// -/// 8 ms is half a 60 Hz frame: a keystroke waits at most one of those before it -/// is on the wire (4 ms on average), and the frame it causes needs no wait at -/// all — it lands in the poll the moment the session writes it. The price is -/// 125 poll rounds a second on a frontend nobody is touching, measured below -/// the noise of what an idle pardes already costs: on an i7-11700 at 100 Hz -/// jiffies an idle attached frontend used 0.16% of one core over 60 s and 0.18% -/// over 120 s, against 0.11% and 0.31% for an idle in-process session on the -/// same screen over the same windows. Reach for an eventfd and a waker thread — -/// fuse.zig's `pollLoop` is the pattern — only if that stops being true. +// Input arrives through frontend queues, so attached clients bound socket waits to half a frame. pub const poll_ms: u32 = 8; /// One round of waiting for the greeting, and how many of them. The COUNT is @@ -513,22 +522,8 @@ pub const Attempt = union(enum) { lost: anyerror, }; -/// Resolve a name, connect to it, and WAIT FOR THE WELCOME. On every failure -/// path this closes whatever it opened, so a caller that gets anything but -/// `.greeted` has nothing to clean up. -/// -/// The waiting is the point, and it is why this function exists rather than -/// each frontend calling `resolve` and `open` in turn. `open` is not a -/// handshake — it connects and writes the hello, and the `welcome` or the -/// `refuse` arrives later through this loop. A frontend that treats a -/// successful `connect(2)` as proof of attachment will tear its local session -/// down — reap its pane shells, unmount its control filesystem, free every -/// undo history — and only then discover `refuse .version`, which is the -/// routine case: `zig build` replaces the binary under a running session, so -/// two protocol versions on one machine is expected rather than exotic. The -/// contract the `Attach` word owes is that a failed attach changes NOTHING, and -/// that contract can only be kept by a caller that has the welcome in hand -/// before it starts destroying things. +/// Wait for welcome before the caller replaces its session; connect alone can +/// still lead to a version refusal. Every non-greeted result owns no resources. pub fn attempt(gpa: std.mem.Allocator, requested: []const u8, cols: u16, rows: u16) Attempt { var buf: [server.path_max]u8 = undefined; const name = switch (resolve(&buf, requested)) { @@ -620,7 +615,16 @@ const Harness = struct { errdefer h.arena.deinit(); // `io` is not optional on `Session`: the daemon does the file watching // and the theme scan itself now, and both of those take a `std.Io`. - h.session = .{ .gpa = testing.allocator, .io = std.testing.io, .core = h.core, .cols = cols, .rows = rows }; + h.session = .{ + .gpa = testing.allocator, + .worker_gpa = testing.allocator, + .io = std.testing.io, + .core = h.core, + .cols = cols, + .rows = rows, + }; + try h.session.initAsync(); + errdefer h.session.deinit(); h.name = "s"; try testing.expect(h.session.listen(h.name)); // The pre-loop drain tty.zig has, for its reason: the startup spawns are @@ -655,18 +659,28 @@ const Harness = struct { /// for hosts whose worker threads post from off the loop. fn pump(h: *Harness) !void { const host = h.session.host(); - host.vtable.pull_wait_input.?(host.ctx, 20); + host.vtable.wait_input.?(host.ctx, 20); while (h.core.nextEffect()) |e| h.core.perform(e); if (h.core.quit) return; _ = h.arena.reset(.retain_capacity); const surface = try h.core.render(h.arena.allocator()); - host.vtable.push_present.?(host.ctx, surface); + host.vtable.present.?(host.ctx, surface); } - /// Pump until this client has the message we are waiting for. Bounded, so a - /// broken transport fails a test rather than hanging the suite. + /// The round budget every `pumpUntil*` below shares. A round moves at most + /// one socket buffer, because nothing here is concurrent: the session + /// flushes until `EAGAIN`, and only then does the client read. That buffer + /// is 8 KiB on darwin (`net.local.stream.sendspace`) against linux's 208 + /// KiB, which is the same asymmetry the slow-frontend test at the bottom of + /// this file already had to say out loud — and a full frame of the largest + /// grid the protocol carries is near a megabyte, so 64 rounds is a linux- + /// only number. High enough for that frame on the smaller buffer, and still + /// a bound: a broken transport fails a test rather than hanging the suite. + const rounds = 256; + + /// Pump until this client has the message we are waiting for. fn pumpUntil(h: *Harness, c: *Client, comptime want: std.meta.Tag(wire.ServerMsg)) !wire.ServerMsg { - for (0..64) |_| { + for (0..rounds) |_| { try h.pump(); try c.wait(5); while (try c.next()) |msg| if (std.meta.activeTag(msg) == want) return msg; @@ -679,7 +693,7 @@ const Harness = struct { /// input queued (a `Look` on a directory emits a spawn) lands a frame /// later, and the frame in between legitimately says nothing. fn pumpUntilChange(h: *Harness, c: *Client) !wire.Frame { - for (0..64) |_| { + for (0..rounds) |_| { const msg = try h.pumpUntil(c, .frame); if (msg.frame.nruns > 0) return msg.frame; } @@ -691,7 +705,7 @@ const Harness = struct { /// because a resize is announced when the session settles it, which may be /// one empty frame after the pump that caused it. fn pumpUntilGrid(h: *Harness, c: *Client, cols: u16, rows: u16) !void { - for (0..64) |_| { + for (0..rounds) |_| { try h.pump(); try c.wait(5); while (try c.next()) |_| {} @@ -706,7 +720,7 @@ const Harness = struct { /// what a shared session promises is that they CONVERGE, which is what this /// waits for. fn pumpUntilSameScreen(h: *Harness, a: *Client, b: *Client) !void { - for (0..64) |_| { + for (0..rounds) |_| { try h.pump(); try a.wait(5); try b.wait(5); @@ -733,7 +747,7 @@ const Harness = struct { /// a transport that really does drop one must fail as a wrong screen, not /// as a timeout. fn pumpUntilShowsCore(h: *Harness, c: *Client) !void { - for (0..64) |_| { + for (0..rounds) |_| { _ = h.arena.reset(.retain_capacity); if (sameScreen((try h.core.render(h.arena.allocator())).cells, c.grid.items)) return; try h.pump(); @@ -795,6 +809,71 @@ test "detached session: input from a frontend reaches the core and comes back as try h.pumpUntilShowsCore(&c); } +test "detached Restore keeps attached frontends and follows queued frames with a full replacement" { + var h: Harness = undefined; + try h.init(60, 16); + defer h.deinit(); + _ = try h.core.setTestFile("saved body\n"); + var c = try h.attach(60, 16); + defer c.deinit(); + _ = try h.pumpUntil(&c, .frame); + try h.pumpUntilShowsCore(&c); + + const before = h.core; + const fd = h.session.clients[c.slot].fd; + try testing.expectError(error.BadDumpMagic, h.session.restore( + ".{ .magic = \"not-a-pardes-dump\", .theme = \"dark\", .screen = .{ .cols = 60, .rows = 16 } }", + )); + try testing.expectEqual(before, h.session.core); + try testing.expectEqual(fd, h.session.clients[c.slot].fd); + try testing.expect(h.session.clients[c.slot].attached); + + try h.core.dumpState(); + const saved = try testing.allocator.dupe(u8, h.core.dump_out.?); + defer testing.allocator.free(saved); + while (h.core.nextEffect()) |_| {} + _ = try h.core.setTestFile("changed after dump\n"); + try h.session.restore(saved); + h.core = h.session.core; + try testing.expect(h.core != before); + try testing.expectEqual(fd, h.session.clients[c.slot].fd); + try testing.expect(h.session.clients[c.slot].attached); + try testing.expectEqualStrings("saved body\n", h.core.panes[0].?.file.?.content); + try testing.expect(h.session.clients[c.slot].need_full); + var wake = [_]libc.pollfd{.{ .fd = h.session.mailbox.wake[0], .events = poll_in, .revents = 0 }}; + try testing.expectEqual(@as(c_int, 1), libc.poll(&wake, wake.len, 0)); + for (0..Harness.rounds) |_| { + if ((try h.pumpUntil(&c, .frame)).frame.kind == .full) break; + } else return error.NoFullReplacement; + try h.pumpUntilShowsCore(&c); +} + +test "detached selection pipe runs off the loop and returns through the attached frontend" { + var h: Harness = undefined; + try h.init(60, 16); + defer h.deinit(); + const pane = try h.core.setTestFile("one\ntwo\n"); + pane.cur_row = 0; + pane.cur_col = 2; + pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; + var client = try h.attach(60, 16); + defer client.deinit(); + _ = try h.pumpUntil(&client, .frame); + + try client.send(.{ .event = .{ .key = .{ .cp = '|' } } }); + try client.send(.{ .event = .{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } } }); + try client.send(.{ .event = .{ .key = .{ .cp = pardes.Key.enter } } }); + for (0..Harness.rounds) |_| { + try h.pump(); + try client.wait(5); + while (try client.next()) |_| {} + if (std.mem.eql(u8, pane.file.?.content, "ONE\ntwo\n")) break; + } else return error.PipeDidNotComplete; + try testing.expect(h.core.pipe_wait == null); + try testing.expectEqual(@as(usize, 0), h.session.pipe_tasks.len); + try h.pumpUntilShowsCore(&client); +} + test "detached session: two frontends share one screen at the smallest common grid" { var h: Harness = undefined; try h.init(80, 24); @@ -835,6 +914,35 @@ test "detached session: two frontends share one screen at the smallest common gr try h.pumpUntilSameScreen(&a, &b); } +test "detached session: a window past the protocol attaches at the largest grid it carries" { + var h: Harness = undefined; + try h.init(80, 24); + defer h.deinit(); + + // A 4K display at a small font is already past `max_rows`, and until the + // clamp in `open` that hello was a geometry the session's decoder refused: + // `apply` answered `BadValue` with `close(.protocol)` and no `refuse` + // behind it, so a frontend on a big screen was told the session "hung up on + // the connect" — at a session with every slot free. It attaches now, at the + // biggest grid the wire has. + var c = try h.attach(wire.max_cols + 400, wire.max_rows + 70); + defer c.deinit(); + try testing.expectEqual(wire.max_cols, c.cols); + try testing.expectEqual(wire.max_rows, c.rows); + + // ...and the full frame that follows is the 65536-cell one whose single run + // does not fit a u16 count (wire.zig `run_max`), which is the half of this + // the clamp alone would have made universal rather than fixed. + const frame = (try h.pumpUntil(&c, .frame)).frame; + try testing.expectEqual(wire.FrameKind.full, frame.kind); + try testing.expectEqual(@as(usize, @as(usize, wire.max_cols) * wire.max_rows), c.grid.items.len); + // TWO runs and not one, which is the assertion that fails first if the + // encoder's bound goes: this grid's full frame is 65536 painted cells and + // a run counts to 65535. + try testing.expectEqual(@as(u32, 2), frame.nruns); + try h.pumpUntilShowsCore(&c); +} + test "detached session: a frontend that dies takes nothing with it" { var h: Harness = undefined; try h.init(60, 16); @@ -964,26 +1072,21 @@ test "detached session: the seam's own routing rules, per surviving effect" { const host = h.session.host(); // BROADCAST: the yank register is a fact about the session, so every // display it is being watched on gets it. - host.vtable.push_set_clipboard.?(host.ctx, "yank"); + host.vtable.set_clipboard.?(host.ctx, "yank"); try expectBoth(&h, &a, &b, .set_clipboard); - // ORIGIN, ELSE PRIMARY. This is the "only one frontend is asked" rule that - // the shell-forking and file-writing messages used to demonstrate; the - // daemon does that work itself now, so the same claim is made about the two - // effects that still travel. `read_clipboard` is asked ONCE — two frontends - // answering would paste twice for one Ctrl-V, which is the rule host.zig - // states. + // Ask one frontend: two clipboard answers would paste twice. h.session.origin = 1; - host.vtable.pull_read_clipboard.?(host.ctx); + host.vtable.read_clipboard.?(host.ctx); try expectOnly(&h, &b, &a, .read_clipboard); // `open_link` follows the same origin: the browser that opens is the one on // the display of the human who clicked, not the oldest attachment's. - host.vtable.push_open_link.?(host.ctx, "https://x"); + host.vtable.open_link.?(host.ctx, "https://x"); try expectOnly(&h, &b, &a, .open_link); // ...and with no origin it falls back to the primary, which is what a link // opened by something other than a keystroke gets. h.session.origin = 0; - host.vtable.push_open_link.?(host.ctx, "https://y"); + host.vtable.open_link.?(host.ctx, "https://y"); try expectOnly(&h, &a, &b, .open_link); } @@ -1118,17 +1221,14 @@ test "detached session: a frontend that stops reading is dropped, not waited for } try testing.expect(h.session.clients[1].attached); - // Broadcast enough control traffic to pass `out_backlog`. A clipboard - // mirror is the honest vehicle: it is a real `push_` that reaches every - // frontend and carries the yank register, so this is a session yanking a - // lot rather than a synthetic poke. + // Clipboard updates broadcast to every frontend. const text = try testing.allocator.alloc(u8, 256 * 1024); defer testing.allocator.free(text); @memset(text, 'y'); const host = h.session.host(); for (0..24) |_| { if (!h.session.clients[1].attached) break; - host.vtable.push_set_clipboard.?(host.ctx, text); + host.vtable.set_clipboard.?(host.ctx, text); // Read `good` back to EMPTY before the next mirror, rather than // pumping once and taking whatever one write fitted. One pump moves at // most one socket buffer, and that buffer is 8 KiB here |
