diff options
Diffstat (limited to 'src/detached/wire.zig')
| -rw-r--r-- | src/detached/wire.zig | 95 |
1 files changed, 90 insertions, 5 deletions
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). |
