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