summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/detached.md17
-rw-r--r--src/CHANGELOG.md24
-rw-r--r--src/detached/client.zig85
-rw-r--r--src/detached/wire.zig95
4 files changed, 203 insertions, 18 deletions
diff --git a/docs/detached.md b/docs/detached.md
index 58a2f9c6..0902f78a 100644
--- a/docs/detached.md
+++ b/docs/detached.md
@@ -255,6 +255,13 @@ Stated rather than papered over:
* **The screen is shared, at the smallest common grid.** Two frontends of
different sizes converge on the smaller; the larger window letterboxes. Same
semantics as tmux.
+* **A frontend asks for at most `max_cols` x `max_rows`** (512x128, above).
+ A window bigger than that — a 4K display at a small font is already past 128
+ rows — attaches at 512x128 and letterboxes the rest, exactly as it does
+ beside a smaller frontend. `client.zig` clamps the hello and every resize,
+ because the geometry itself does not fit the wire: an unclamped one was
+ refused by the session's decoder as `BadValue`, and that refusal reaches the
+ frontend as a bare hangup with no reason attached.
* **No LSP and no selection pipe** in a detached session. The daemon implements
**seventeen** of `Host.VTable`'s **twenty-one** methods — fewer than the tty
and SDL shells, which install nineteen each, everything but
@@ -299,8 +306,8 @@ Stated rather than papered over:
## Tests
-`zig build unit-test` runs eleven tests in `src/detached/client.zig`. Ten drive a
-real core over a real socket: a frontend is greeted
+`zig build unit-test` runs twelve tests in `src/detached/client.zig`. Eleven
+drive a real core over a real socket: a frontend is greeted
and sent a screen; input comes back as a diff; two frontends share one screen
at the smallest common grid; a frontend that dies takes nothing with it; a
wrong-version peer is refused, loudly; a peer that sends an undefined tag byte
@@ -309,9 +316,11 @@ the grid where the last one left it, and lets the next one take it over; the
three surviving effects route as documented above — `set_clipboard` to both
frontends, `read_clipboard` and `open_link` to the origin, and to the primary
once the origin is gone; the client table refuses rather than queues; and a
-frontend that stops reading is dropped.
+frontend that stops reading is dropped; and a window bigger than the protocol's
+grid attaches at `max_cols` x `max_rows` rather than being refused, which is
+also the one test that drives a full frame of the largest grid the wire carries.
-The eleventh builds no harness, opens no socket and touches no core, and that is
+The twelfth builds no harness, opens no socket and touches no core, and that is
the point: it pins the DESIGN rather than the behaviour, and `wire.zig` has its
mirror, one test per direction. "A frontend is never asked to fork, write, or
watch" walks
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 4fef47fc..66c9fb0c 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -26,6 +26,30 @@
own in the process: the macOS build roots at `macos.zig`, so the one in
`main.zig` had never run there — in the shell with the least useful stderr of
the four.
+- A big screen can attach to a detached session. The wire's grid ceiling is
+ 512x128 and no frontend ever clamped to it — the hello carried the window
+ raw — so a 4K display at a small font, which is already past 128 rows, had
+ its geometry refused by the session's decoder as `BadValue`. That path
+ answers with `close(.protocol)` and no `refuse` behind it, so the frontend
+ reported the one thing a bare hangup can mean: "that session hung up on the
+ connect", at a session with all 32 slots free. `client.zig` now asks for the
+ largest grid the protocol carries, which is what its own GEOMETRY note
+ already promises a frontend gets — the session is drawn at its own size in
+ the corner of a bigger window, exactly as when another frontend is the
+ smaller one. A zero geometry is dropped rather than clamped, because the
+ session grid is the smallest common one and a frontend reporting 1 would
+ collapse everybody else: `TIOCGWINSZ` answers 0x0 during a teardown and the
+ tty shell forwarded it, so that was the same mute hangup by another route.
+ The clamp alone would have replaced one bug with a worse one: `max_cols *
+ max_rows` is 65536 and a run's length prefix is a `u16`, so the single grid
+ legal at both bounds is the one grid whose full frame — and an attach always
+ produces a full frame — cannot be described by one run. It panicked on the
+ `@intCast` in a safe build and was illegal behaviour in a fast one. The
+ encoder splits the run instead, which `frameBound` had already paid for, and
+ the protocol version is bumped to 2: the geometry a v2 frontend now asks for
+ is one a v1 daemon panics encoding, so a mixed pair — `zig build` replacing
+ the binary under a running session — meets a `Refusal.version` instead of
+ losing every pane shell the daemon owns.
- A filtered terminal costs what an unfiltered one does. `Filter`'s second
stage asked `RGB.contrast` for every cell it painted, and that call ends in
`std.math.pow` six times over — a libm round trip per cell, per frame, to
diff --git a/src/detached/client.zig b/src/detached/client.zig
index fc39164b..f317b2e2 100644
--- a/src/detached/client.zig
+++ b/src/detached/client.zig
@@ -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
@@ -663,10 +691,20 @@ const Harness = struct {
host.vtable.push_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 +717,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 +729,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 +744,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 +771,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();
@@ -835,6 +873,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);
diff --git a/src/detached/wire.zig b/src/detached/wire.zig
index 23a85158..8281fc63 100644
--- a/src/detached/wire.zig
+++ b/src/detached/wire.zig
@@ -84,7 +84,17 @@ const pardes = @import("../pardes.zig");
/// machine — `zig build` replaces the binary under a running session — and a
/// frontend decoding another version's frame layout would paint garbage and
/// blame the terminal.
-pub const version: u16 = 1;
+///
+/// 2: the layout did not move, but what a frontend is ALLOWED TO ASK FOR did.
+/// A frontend now clamps its window to `max_cols` x `max_rows` instead of
+/// sending it raw (client.zig), and a 512x128 grid is a full frame a v1 daemon
+/// PANICS encoding — its run length overflowed a u16 by exactly one cell, see
+/// `run_max`. A v1 session refused that geometry outright, so nothing was ever
+/// lost by refusing the connection instead; a v1 daemon meeting a v2 frontend
+/// answers `Refusal.version`, which says so, rather than dying with every pane
+/// shell it owns. This is the case the paragraph above is about: `zig build`
+/// replaces the binary under a running session.
+pub const version: u16 = 2;
pub const Error = error{
/// The message ended inside a field.
@@ -111,9 +121,17 @@ pub const Error = error{
/// The largest grid this protocol carries. `Surface.cols`/`rows` are u16, so
/// these are protocol bounds rather than type bounds, and they exist because
/// `max_payload` below is derived from them: a decoder that accepts 65535
-/// columns accepts a 25 GiB frame prefix. A 4K display at a 6-pixel font is
-/// about 340 columns and 110 rows, so this is roughly 1.5x the largest grid
-/// any real terminal has, and the board's own is 56x14.
+/// columns accepts a 25 GiB frame prefix. The board's own grid is 56x14 and a
+/// terminal's is usually near 200x50.
+///
+/// NOT a ceiling above every real display, which is what this comment used to
+/// claim: a 4K window at the SDL shell's minimum 8-pixel font is around 768
+/// columns by 216 rows, and a tty on the same screen passes 128 rows at any
+/// ordinary line height. Those windows attach at 512x128 and letterbox the
+/// rest (client.zig clamps), rather than being refused as they were. Raising
+/// the pair instead would have been a bigger change than it looks: `frameBound`
+/// stays well inside `max_payload`, but a themed full frame at 512x128 is
+/// already ~0.9 MiB against server.zig's 1 MiB `out_backlog`.
pub const max_cols: u16 = 512;
pub const max_rows: u16 = 128;
@@ -127,6 +145,24 @@ const cell_max = 1 + 1 + 7 + 4 + 4 + 1 + 1 + 1;
/// encoder coalesces against (see `encodeFrame`).
const run_header = 4 + 2;
+/// ...and the longest run that `count:u16` can describe, which is EXACTLY ONE
+/// SHORT of the largest grid this protocol carries: `max_cols * max_rows` is
+/// 512*128 = 65536, and `maxInt(u16)` is 65535.
+///
+/// A full frame of that grid is ONE run over all of it whenever the theme has
+/// a background: `render` fills the surface and every pane then repaints its
+/// text area, and `Surface.set` clears `default`, so `sendCell`'s `!default`
+/// holds for every cell. (Under a theme with `bg = null` — `dark` — untouched
+/// body cells stay default and a stretch of six of them breaks the run, so the
+/// overflow was theme-dependent as well as geometry-dependent, which is the
+/// worst kind of latent.) `encodeFrame`'s `@intCast(run_end - start)` then
+/// panicked in a safe build and was illegal behaviour in a fast one — LLVM
+/// happens to truncate to zero, which the far side refuses as `BadValue`, but
+/// nothing promises that. That grid is what a frontend with a big window now
+/// asks for (client.zig clamps to it), so the meeting point went from
+/// unreachable to routine, and the encoder splits the run instead.
+const run_max = std.math.maxInt(u16);
+
/// `kind:u8 + cols:u16 + rows:u16 + cursor(6) + nruns:u32`.
const frame_head = 1 + 2 + 2 + 6 + 4;
@@ -791,7 +827,11 @@ pub fn encodeFrame(
const start = i;
var run_end = i + 1;
i += 1;
- while (i < cells.len) {
+ // `i - start` and not `run_end - start`, because the gap lookahead
+ // below moves `i` ahead of `run_end` by up to `run_header - 1` before
+ // the next iteration adopts it: bounding the cursor bounds the run,
+ // and bounding the run afterwards would not.
+ while (i < cells.len and i - start < run_max) {
if (sendCell(cells, prev, full, i)) {
run_end = i + 1;
i += 1;
@@ -1382,6 +1422,51 @@ test "detached wire: a full frame carries the grid, a diff carries the change" {
}
}
+test "detached wire: the largest grid is one cell past a run, and survives it" {
+ // `max_cols * max_rows` is 65536 and a run's `count` is a u16, so THE ONE
+ // grid this protocol calls legal at both bounds is the one grid whose full
+ // frame cannot be described by a single run. It reached nobody while a
+ // frontend sent its window raw — a display that big had its hello refused
+ // before any frame was composed — and it is the ordinary case now that a
+ // frontend clamps to exactly this. In a safe build the `@intCast` panicked
+ // and took the session down; in a fast one it wrote a zero-length run the
+ // frontend answered with `BadValue`.
+ const cols = max_cols;
+ const rows = max_rows;
+ const n = @as(usize, cols) * rows;
+ const gpa = testing.allocator;
+
+ const cells = try gpa.alloc(pardes.Cell, n);
+ defer gpa.free(cells);
+ const mirror = try gpa.alloc(pardes.Cell, n);
+ defer gpa.free(mirror);
+ const buf = try gpa.alloc(u8, frameBound(cols, rows));
+ defer gpa.free(buf);
+
+ // Every cell painted, which is what `render` produces and therefore what a
+ // full frame always is: one run, if a run could be that long.
+ for (cells) |*c| c.* = .{ .text = "a".* ++ @as([6]u8, @splat(0)), .len = 1, .default = false };
+ @memset(mirror, .{});
+
+ const bytes = try encodeFrame(buf, cols, rows, null, cells, &.{});
+ const f = (try framed(bytes)).?;
+ const msg = (try decodeServer(f.tag, f.payload)).frame;
+ try testing.expectEqual(FrameKind.full, msg.kind);
+ // Split, and split as late as it can be: 65535 cells then 1.
+ try testing.expectEqual(@as(u32, 2), msg.nruns);
+ try msg.apply(mirror);
+ try expectGridEqual(cells, mirror);
+ // ...at exactly the two headers the split costs and not one byte more.
+ // `bytes.len <= frameBound(...)` would prove nothing here — the buffer IS
+ // `frameBound` bytes, so an over-run is `NoSpace` above, not a false
+ // assertion here — and what wants pinning is that the second run is the
+ // whole of the cost.
+ try testing.expectEqual(
+ @as(usize, header_len + frame_head + 2 * run_header + n * 8),
+ bytes.len,
+ );
+}
+
test "detached wire: the diff is worth having, in bytes, on the board's grid" {
// The numbers quoted in `encodeFrame`'s comment, asserted so the claim
// cannot rot. 56x14 is the ESP32-P4 board's default grid (esp32p4.zig).