a73x

44feadf5

feat: pin selection replies to a history watermark

a73x   2026-08-18 12:27

Commit message
feat: pin selection replies to a history watermark

src/client_core.zig
Old New
@@ -301,12 +301,12 @@ test "client core ignores stale selection reply then accepts matching reply once
301 .anchor = .{ .row = 1, .col = 2 }, 301 .anchor = .{ .row = 1, .col = 2 },
302 .active = .{ .row = 3, .col = 4 }, 302 .active = .{ .row = 3, .col = 4 },
303 }); 303 });
304 const stale = [_]u8{ 21, 0, 0, 0, 0, 'n', 'o' }; 304 const stale = [_]u8{ 21, 0, 0, 0, 0, 0, 0, 0, 0, 'n', 'o' };
305 const matching = [_]u8{ 22, 0, 0, 0, 0, 'o', 'k' }; 305 const matching = [_]u8{ 22, 0, 0, 0, 0, 8, 0, 0, 0, 'o', 'k' };
306 306
307 try expectIgnored(core.receive(.selection_reply, &stale)); 307 try expectIgnored(core.receive(.selection_reply, &stale));
308 try std.testing.expectEqual(@as(?u32, 22), core.pending_selection_id); 308 try std.testing.expectEqual(@as(?u32, 22), core.pending_selection_id);
309 try expectSelection(core.receive(.selection_reply, &matching), 22, .ok, "ok"); 309 try expectSelection(core.receive(.selection_reply, &matching), 22, .ok, 8, "ok");
310 try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id); 310 try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id);
311 try expectIgnored(core.receive(.selection_reply, &matching)); 311 try expectIgnored(core.receive(.selection_reply, &matching));
312 } 312 }
@@ -324,9 +324,9 @@ test "client core latest selection begin replaces the older pending id" {
324 .active = .{ .row = 4, .col = 5 }, 324 .active = .{ .row = 4, .col = 5 },
325 }); 325 });
326 326
327 try expectIgnored(core.receive(.selection_reply, &.{ 7, 0, 0, 0, 0, 'x' })); 327 try expectIgnored(core.receive(.selection_reply, &.{ 7, 0, 0, 0, 0, 0, 0, 0, 0, 'x' }));
328 try std.testing.expectEqual(@as(?u32, 8), core.pending_selection_id); 328 try std.testing.expectEqual(@as(?u32, 8), core.pending_selection_id);
329 try expectSelection(core.receive(.selection_reply, &.{ 8, 0, 0, 0, 0, 'y' }), 8, .ok, "y"); 329 try expectSelection(core.receive(.selection_reply, &.{ 8, 0, 0, 0, 0, 0, 0, 0, 0, 'y' }), 8, .ok, 0, "y");
330 } 330 }
331 331
332 test "client core malformed matching selection reply preserves pending request" { 332 test "client core malformed matching selection reply preserves pending request" {
@@ -337,9 +337,9 @@ test "client core malformed matching selection reply preserves pending request"
337 .active = .{ .row = 0, .col = 0 }, 337 .active = .{ .row = 0, .col = 0 },
338 }); 338 });
339 339
340 try expectIgnored(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 1, 'x' })); 340 try expectIgnored(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 1, 0, 0, 0, 0, 'x' }));
341 try std.testing.expectEqual(@as(?u32, 9), core.pending_selection_id); 341 try std.testing.expectEqual(@as(?u32, 9), core.pending_selection_id);
342 try expectSelection(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 0 }), 9, .ok, ""); 342 try expectSelection(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 0, 0, 0, 0, 0 }), 9, .ok, 0, "");
343 } 343 }
344 344
345 test "client core delivers every matching non-ok selection status with empty text" { 345 test "client core delivers every matching non-ok selection status with empty text" {
@@ -352,10 +352,10 @@ test "client core delivers every matching non-ok selection status with empty tex
352 .anchor = .{ .row = 0, .col = 0 }, 352 .anchor = .{ .row = 0, .col = 0 },
353 .active = .{ .row = 0, .col = 0 }, 353 .active = .{ .row = 0, .col = 0 },
354 }); 354 });
355 var payload = [_]u8{ 0, 0, 0, 0, @intFromEnum(status) }; 355 var payload = [_]u8{ 0, 0, 0, 0, @intFromEnum(status), 0, 0, 0, 0 };
356 std.mem.writeInt(u32, payload[0..4], id, .little); 356 std.mem.writeInt(u32, payload[0..4], id, .little);
357 357
358 try expectSelection(core.receive(.selection_reply, &payload), id, status, ""); 358 try expectSelection(core.receive(.selection_reply, &payload), id, status, 0, "");
359 try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id); 359 try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id);
360 } 360 }
361 } 361 }
@@ -367,11 +367,11 @@ test "client core selection reply text borrows the frame payload" {
367 .anchor = .{ .row = 0, .col = 0 }, 367 .anchor = .{ .row = 0, .col = 0 },
368 .active = .{ .row = 0, .col = 0 }, 368 .active = .{ .row = 0, .col = 0 },
369 }); 369 });
370 var payload = [_]u8{ 1, 0, 0, 0, 0, 'h', 'i' }; 370 var payload = [_]u8{ 1, 0, 0, 0, 0, 4, 0, 0, 0, 'h', 'i' };
371 371
372 const result = core.receive(.selection_reply, &payload); 372 const result = core.receive(.selection_reply, &payload);
373 payload[5] = 'H'; 373 payload[proto.selection_reply_prefix_len] = 'H';
374 try expectSelection(result, 1, .ok, "Hi"); 374 try expectSelection(result, 1, .ok, 4, "Hi");
375 } 375 }
376 376
377 fn expectIgnored(result: Result) !void { 377 fn expectIgnored(result: Result) !void {
@@ -394,6 +394,7 @@ fn expectSelection(
394 result: Result, 394 result: Result,
395 id: u32, 395 id: u32,
396 status: proto.SelectionStatus, 396 status: proto.SelectionStatus,
397 history_rows: u32,
397 text: []const u8, 398 text: []const u8,
398 ) !void { 399 ) !void {
399 switch (result) { 400 switch (result) {
@@ -401,6 +402,7 @@ fn expectSelection(
401 .selection => |selection| { 402 .selection => |selection| {
402 try std.testing.expectEqual(id, selection.id); 403 try std.testing.expectEqual(id, selection.id);
403 try std.testing.expectEqual(status, selection.status); 404 try std.testing.expectEqual(status, selection.status);
405 try std.testing.expectEqual(history_rows, selection.history_rows);
404 try std.testing.expectEqualStrings(text, selection.text); 406 try std.testing.expectEqualStrings(text, selection.text);
405 }, 407 },
406 }, 408 },
src/engine.zig
Old New
@@ -188,6 +188,12 @@ pub const Engine = struct {
188 pub const Status = enum { ok, invalid, too_large }; 188 pub const Status = enum { ok, invalid, too_large };
189 189
190 status: Status, 190 status: Status,
191 /// `historyRows()` of the screen this extraction resolved its pins
192 /// against, sampled here rather than by the caller so it cannot name
193 /// a different moment than the text does. Absolute screen rows are
194 /// counted from the oldest RETAINED row, so a page eviction renames
195 /// every one of them; this is what lets a requester notice.
196 history_rows: u32 = 0,
191 text: ?[]u8 = null, 197 text: ?[]u8 = null,
192 198
193 pub fn deinit(self: SelectionExtract, alloc: std.mem.Allocator) void { 199 pub fn deinit(self: SelectionExtract, alloc: std.mem.Allocator) void {
@@ -438,19 +444,20 @@ pub const Engine = struct {
438 active_col: u16, 444 active_col: u16,
439 max_bytes: usize, 445 max_bytes: usize,
440 ) !SelectionExtract { 446 ) !SelectionExtract {
447 const history_rows = self.historyRows();
441 if (anchor_col >= self.term.cols or active_col >= self.term.cols) { 448 if (anchor_col >= self.term.cols or active_col >= self.term.cols) {
442 return .{ .status = .invalid }; 449 return .{ .status = .invalid, .history_rows = history_rows };
443 } 450 }
444 451
445 const screen = self.term.screens.active; 452 const screen = self.term.screens.active;
446 const anchor = screen.pages.pin(.{ .screen = .{ 453 const anchor = screen.pages.pin(.{ .screen = .{
447 .x = anchor_col, 454 .x = anchor_col,
448 .y = anchor_row, 455 .y = anchor_row,
449 } }) orelse return .{ .status = .invalid }; 456 } }) orelse return .{ .status = .invalid, .history_rows = history_rows };
450 const active = screen.pages.pin(.{ .screen = .{ 457 const active = screen.pages.pin(.{ .screen = .{
451 .x = active_col, 458 .x = active_col,
452 .y = active_row, 459 .y = active_row,
453 } }) orelse return .{ .status = .invalid }; 460 } }) orelse return .{ .status = .invalid, .history_rows = history_rows };
454 461
455 const buffer = try alloc.alloc(u8, max_bytes); 462 const buffer = try alloc.alloc(u8, max_bytes);
456 defer alloc.free(buffer); 463 defer alloc.free(buffer);
@@ -467,11 +474,12 @@ pub const Engine = struct {
467 formatter.content = .{ .selection = vt.Selection.init(anchor, active, false) }; 474 formatter.content = .{ .selection = vt.Selection.init(anchor, active, false) };
468 formatter.format(&writer) catch |err| switch (err) { 475 formatter.format(&writer) catch |err| switch (err) {
469 // A fixed writer's sole failure mode is exhausting `buffer`. 476 // A fixed writer's sole failure mode is exhausting `buffer`.
470 error.WriteFailed => return .{ .status = .too_large }, 477 error.WriteFailed => return .{ .status = .too_large, .history_rows = history_rows },
471 }; 478 };
472 479
473 return .{ 480 return .{
474 .status = .ok, 481 .status = .ok,
482 .history_rows = history_rows,
475 .text = try alloc.dupe(u8, writer.buffered()), 483 .text = try alloc.dupe(u8, writer.buffered()),
476 }; 484 };
477 } 485 }
src/protocol.zig
Old New
@@ -225,7 +225,7 @@ pub const SelectionReq = struct {
225 225
226 pub const selection_text_max: usize = 1024 * 1024; 226 pub const selection_text_max: usize = 1024 * 1024;
227 pub const selection_req_len: usize = 16; 227 pub const selection_req_len: usize = 16;
228 pub const selection_reply_prefix_len: usize = 5; 228 pub const selection_reply_prefix_len: usize = 9;
229 229
230 pub fn encodeSelectionReq(req: SelectionReq) [selection_req_len]u8 { 230 pub fn encodeSelectionReq(req: SelectionReq) [selection_req_len]u8 {
231 var buf: [selection_req_len]u8 = undefined; 231 var buf: [selection_req_len]u8 = undefined;
@@ -262,6 +262,21 @@ pub const SelectionStatus = enum(u8) {
262 pub const SelectionReply = struct { 262 pub const SelectionReply = struct {
263 id: u32, 263 id: u32,
264 status: SelectionStatus, 264 status: SelectionStatus,
265 /// Retained history rows on the screen the text was extracted from,
266 /// sampled at extraction time. Absolute screen rows are counted from
267 /// the OLDEST RETAINED row, so they are not a stable name for a line:
268 /// when the page list evicts a page, every absolute row shifts under a
269 /// request already in flight, and a reply pinned to the old numbering
270 /// is `.ok`, valid UTF-8, and the wrong text.
271 ///
272 /// This is the watermark that makes that visible, and it is a watermark
273 /// rather than a lease on purpose — the daemon holds no per-client
274 /// selection state, so the requester compares and decides. Ordinary
275 /// output RAISES this without moving row zero (new rows are appended
276 /// below); only eviction lowers it. Measured on a 5×3 grid: history
277 /// climbed to 10000 and then dropped to 5214 in one step, with absolute
278 /// row zero changing identity at exactly that drop.
279 history_rows: u32,
265 /// Borrowed from the frame payload and valid only as long as that 280 /// Borrowed from the frame payload and valid only as long as that
266 /// payload remains alive and unchanged. 281 /// payload remains alive and unchanged.
267 text: []const u8, 282 text: []const u8,
@@ -276,6 +291,7 @@ pub fn encodeSelectionReply(
276 alloc: std.mem.Allocator, 291 alloc: std.mem.Allocator,
277 id: u32, 292 id: u32,
278 status: SelectionStatus, 293 status: SelectionStatus,
294 history_rows: u32,
279 text_value: []const u8, 295 text_value: []const u8,
280 ) !void { 296 ) !void {
281 switch (status) { 297 switch (status) {
@@ -291,6 +307,7 @@ pub fn encodeSelectionReply(
291 var prefix: [selection_reply_prefix_len]u8 = undefined; 307 var prefix: [selection_reply_prefix_len]u8 = undefined;
292 std.mem.writeInt(u32, prefix[0..4], id, .little); 308 std.mem.writeInt(u32, prefix[0..4], id, .little);
293 prefix[4] = @intFromEnum(status); 309 prefix[4] = @intFromEnum(status);
310 std.mem.writeInt(u32, prefix[5..9], history_rows, .little);
294 try out.appendSlice(alloc, &prefix); 311 try out.appendSlice(alloc, &prefix);
295 try out.appendSlice(alloc, text_value); 312 try out.appendSlice(alloc, text_value);
296 } 313 }
@@ -311,6 +328,7 @@ pub fn decodeSelectionReply(payload: []const u8) !SelectionReply {
311 return .{ 328 return .{
312 .id = std.mem.readInt(u32, payload[0..4], .little), 329 .id = std.mem.readInt(u32, payload[0..4], .little),
313 .status = status, 330 .status = status,
331 .history_rows = std.mem.readInt(u32, payload[5..9], .little),
314 .text = text_value, 332 .text = text_value,
315 }; 333 };
316 } 334 }
@@ -1660,32 +1678,40 @@ test "selection reply validates status and text shape" {
1660 var payload: std.ArrayList(u8) = .empty; 1678 var payload: std.ArrayList(u8) = .empty;
1661 defer payload.deinit(alloc); 1679 defer payload.deinit(alloc);
1662 1680
1663 try encodeSelectionReply(&payload, alloc, 7, .ok, "hello"); 1681 try encodeSelectionReply(&payload, alloc, 7, .ok, 0x11223344, "hello");
1664 try std.testing.expectEqualSlices(u8, &.{ 7, 0, 0, 0, 0, 'h', 'e', 'l', 'l', 'o' }, payload.items); 1682 try std.testing.expectEqualSlices(
1683 u8,
1684 &.{ 7, 0, 0, 0, 0, 0x44, 0x33, 0x22, 0x11, 'h', 'e', 'l', 'l', 'o' },
1685 payload.items,
1686 );
1665 const ok = try decodeSelectionReply(payload.items); 1687 const ok = try decodeSelectionReply(payload.items);
1666 try std.testing.expectEqual(@as(u32, 7), ok.id); 1688 try std.testing.expectEqual(@as(u32, 7), ok.id);
1667 try std.testing.expectEqual(SelectionStatus.ok, ok.status); 1689 try std.testing.expectEqual(SelectionStatus.ok, ok.status);
1690 try std.testing.expectEqual(@as(u32, 0x11223344), ok.history_rows);
1668 try std.testing.expectEqualStrings("hello", ok.text); 1691 try std.testing.expectEqualStrings("hello", ok.text);
1669 1692
1670 payload.clearRetainingCapacity(); 1693 payload.clearRetainingCapacity();
1671 try encodeSelectionReply(&payload, alloc, 9, .invalid, ""); 1694 try encodeSelectionReply(&payload, alloc, 9, .invalid, 5, "");
1672 try std.testing.expectEqualSlices(u8, &.{ 9, 0, 0, 0, 1 }, payload.items); 1695 try std.testing.expectEqualSlices(u8, &.{ 9, 0, 0, 0, 1, 5, 0, 0, 0 }, payload.items);
1673 const invalid = try decodeSelectionReply(payload.items); 1696 const invalid = try decodeSelectionReply(payload.items);
1674 try std.testing.expectEqual(@as(u32, 9), invalid.id); 1697 try std.testing.expectEqual(@as(u32, 9), invalid.id);
1675 try std.testing.expectEqual(SelectionStatus.invalid, invalid.status); 1698 try std.testing.expectEqual(SelectionStatus.invalid, invalid.status);
1699 try std.testing.expectEqual(@as(u32, 5), invalid.history_rows);
1676 try std.testing.expectEqual(@as(usize, 0), invalid.text.len); 1700 try std.testing.expectEqual(@as(usize, 0), invalid.text.len);
1677 1701
1678 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0xff })); 1702 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0xff, 0, 0, 0, 0 }));
1679 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 1, 'x' })); 1703 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 1, 0, 0, 0, 0, 'x' }));
1680 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0, 0xff })); 1704 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0xff }));
1681 1705
1682 const prefix = [_]u8{ 1, 0, 0, 0, 0 }; 1706 // The watermark is part of the prefix, so a reply carrying only the
1707 // pre-watermark five bytes is short, not a legacy reply to interpret.
1708 const prefix = [_]u8{ 1, 0, 0, 0, 0, 0, 0, 0, 0 };
1683 for (0..selection_reply_prefix_len) |len| { 1709 for (0..selection_reply_prefix_len) |len| {
1684 try std.testing.expectError(error.BadPayload, decodeSelectionReply(prefix[0..len])); 1710 try std.testing.expectError(error.BadPayload, decodeSelectionReply(prefix[0..len]));
1685 } 1711 }
1686 } 1712 }
1687 1713
1688 test "selection status discriminants have exact five-byte golden replies" { 1714 test "selection status discriminants have exact nine-byte golden replies" {
1689 const alloc = std.testing.allocator; 1715 const alloc = std.testing.allocator;
1690 const cases = [_]struct { status: SelectionStatus, wire: u8 }{ 1716 const cases = [_]struct { status: SelectionStatus, wire: u8 }{
1691 .{ .status = .ok, .wire = 0 }, 1717 .{ .status = .ok, .wire = 0 },
@@ -1696,15 +1722,16 @@ test "selection status discriminants have exact five-byte golden replies" {
1696 for (cases) |case| { 1722 for (cases) |case| {
1697 var payload: std.ArrayList(u8) = .empty; 1723 var payload: std.ArrayList(u8) = .empty;
1698 defer payload.deinit(alloc); 1724 defer payload.deinit(alloc);
1699 try encodeSelectionReply(&payload, alloc, 0x01020304, case.status, ""); 1725 try encodeSelectionReply(&payload, alloc, 0x01020304, case.status, 0x0a0b0c0d, "");
1700 try std.testing.expectEqualSlices( 1726 try std.testing.expectEqualSlices(
1701 u8, 1727 u8,
1702 &.{ 0x04, 0x03, 0x02, 0x01, case.wire }, 1728 &.{ 0x04, 0x03, 0x02, 0x01, case.wire, 0x0d, 0x0c, 0x0b, 0x0a },
1703 payload.items, 1729 payload.items,
1704 ); 1730 );
1705 const reply = try decodeSelectionReply(payload.items); 1731 const reply = try decodeSelectionReply(payload.items);
1706 try std.testing.expectEqual(@as(u32, 0x01020304), reply.id); 1732 try std.testing.expectEqual(@as(u32, 0x01020304), reply.id);
1707 try std.testing.expectEqual(case.status, reply.status); 1733 try std.testing.expectEqual(case.status, reply.status);
1734 try std.testing.expectEqual(@as(u32, 0x0a0b0c0d), reply.history_rows);
1708 try std.testing.expectEqual(@as(usize, 0), reply.text.len); 1735 try std.testing.expectEqual(@as(usize, 0), reply.text.len);
1709 } 1736 }
1710 } 1737 }
@@ -1717,7 +1744,7 @@ test "selection text accepts the exact cap and rejects one byte more" {
1717 1744
1718 var payload: std.ArrayList(u8) = .empty; 1745 var payload: std.ArrayList(u8) = .empty;
1719 defer payload.deinit(alloc); 1746 defer payload.deinit(alloc);
1720 try encodeSelectionReply(&payload, alloc, 1, .ok, at_cap); 1747 try encodeSelectionReply(&payload, alloc, 1, .ok, 0, at_cap);
1721 try std.testing.expectEqual(selection_reply_prefix_len + selection_text_max, payload.items.len); 1748 try std.testing.expectEqual(selection_reply_prefix_len + selection_text_max, payload.items.len);
1722 const decoded = try decodeSelectionReply(payload.items); 1749 const decoded = try decodeSelectionReply(payload.items);
1723 try std.testing.expectEqual(selection_text_max, decoded.text.len); 1750 try std.testing.expectEqual(selection_text_max, decoded.text.len);
@@ -1725,12 +1752,13 @@ test "selection text accepts the exact cap and rejects one byte more" {
1725 const over_cap = try alloc.alloc(u8, selection_text_max + 1); 1752 const over_cap = try alloc.alloc(u8, selection_text_max + 1);
1726 defer alloc.free(over_cap); 1753 defer alloc.free(over_cap);
1727 @memset(over_cap, 'x'); 1754 @memset(over_cap, 'x');
1728 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, over_cap)); 1755 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, over_cap));
1729 1756
1730 var oversized_payload = try alloc.alloc(u8, selection_reply_prefix_len + selection_text_max + 1); 1757 var oversized_payload = try alloc.alloc(u8, selection_reply_prefix_len + selection_text_max + 1);
1731 defer alloc.free(oversized_payload); 1758 defer alloc.free(oversized_payload);
1732 std.mem.writeInt(u32, oversized_payload[0..4], 1, .little); 1759 std.mem.writeInt(u32, oversized_payload[0..4], 1, .little);
1733 oversized_payload[4] = @intFromEnum(SelectionStatus.ok); 1760 oversized_payload[4] = @intFromEnum(SelectionStatus.ok);
1761 std.mem.writeInt(u32, oversized_payload[5..9], 0, .little);
1734 @memset(oversized_payload[selection_reply_prefix_len..], 'x'); 1762 @memset(oversized_payload[selection_reply_prefix_len..], 'x');
1735 try std.testing.expectError(error.BadPayload, decodeSelectionReply(oversized_payload)); 1763 try std.testing.expectError(error.BadPayload, decodeSelectionReply(oversized_payload));
1736 } 1764 }
@@ -1741,20 +1769,20 @@ test "selection reply validation errors do not modify a reused output buffer" {
1741 defer payload.deinit(alloc); 1769 defer payload.deinit(alloc);
1742 try payload.appendSlice(alloc, "sentinel"); 1770 try payload.appendSlice(alloc, "sentinel");
1743 1771
1744 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .invalid, "x")); 1772 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .invalid, 0, "x"));
1745 try std.testing.expectEqualStrings("sentinel", payload.items); 1773 try std.testing.expectEqualStrings("sentinel", payload.items);
1746 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, &.{0xff})); 1774 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, &.{0xff}));
1747 try std.testing.expectEqualStrings("sentinel", payload.items); 1775 try std.testing.expectEqualStrings("sentinel", payload.items);
1748 1776
1749 const over_cap = try alloc.alloc(u8, selection_text_max + 1); 1777 const over_cap = try alloc.alloc(u8, selection_text_max + 1);
1750 defer alloc.free(over_cap); 1778 defer alloc.free(over_cap);
1751 @memset(over_cap, 'x'); 1779 @memset(over_cap, 'x');
1752 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, over_cap)); 1780 try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, over_cap));
1753 try std.testing.expectEqualStrings("sentinel", payload.items); 1781 try std.testing.expectEqualStrings("sentinel", payload.items);
1754 } 1782 }
1755 1783
1756 test "decoded selection text borrows the payload" { 1784 test "decoded selection text borrows the payload" {
1757 var payload = [_]u8{ 7, 0, 0, 0, 0, 'o', 'n', 'e' }; 1785 var payload = [_]u8{ 7, 0, 0, 0, 0, 3, 0, 0, 0, 'o', 'n', 'e' };
1758 const reply = try decodeSelectionReply(&payload); 1786 const reply = try decodeSelectionReply(&payload);
1759 try std.testing.expectEqualStrings("one", reply.text); 1787 try std.testing.expectEqualStrings("one", reply.text);
1760 payload[selection_reply_prefix_len] = 'O'; 1788 payload[selection_reply_prefix_len] = 'O';
@@ -1765,7 +1793,7 @@ test "selection message values and fixed lengths are pinned" {
1765 try std.testing.expectEqual(@as(u8, 0x0b), @intFromEnum(MsgType.selection_req)); 1793 try std.testing.expectEqual(@as(u8, 0x0b), @intFromEnum(MsgType.selection_req));
1766 try std.testing.expectEqual(@as(u8, 0x90), @intFromEnum(MsgType.selection_reply)); 1794 try std.testing.expectEqual(@as(u8, 0x90), @intFromEnum(MsgType.selection_reply));
1767 try std.testing.expectEqual(@as(usize, 16), selection_req_len); 1795 try std.testing.expectEqual(@as(usize, 16), selection_req_len);
1768 try std.testing.expectEqual(@as(usize, 5), selection_reply_prefix_len); 1796 try std.testing.expectEqual(@as(usize, 9), selection_reply_prefix_len);
1769 try std.testing.expectEqual(@as(usize, 1024 * 1024), selection_text_max); 1797 try std.testing.expectEqual(@as(usize, 1024 * 1024), selection_text_max);
1770 } 1798 }
1771 1799
src/server.zig
Old New
@@ -1079,14 +1079,15 @@ pub const Server = struct {
1079 i: usize, 1079 i: usize,
1080 id: u32, 1080 id: u32,
1081 status: proto.SelectionStatus, 1081 status: proto.SelectionStatus,
1082 history_rows: u32,
1082 value: []const u8, 1083 value: []const u8,
1083 ) void { 1084 ) void {
1084 var payload: std.ArrayList(u8) = .empty; 1085 var payload: std.ArrayList(u8) = .empty;
1085 defer payload.deinit(self.alloc); 1086 defer payload.deinit(self.alloc);
1086 payload.ensureTotalCapacity(self.alloc, proto.selection_reply_prefix_len) catch return; 1087 payload.ensureTotalCapacity(self.alloc, proto.selection_reply_prefix_len) catch return;
1087 proto.encodeSelectionReply(&payload, self.alloc, id, status, value) catch { 1088 proto.encodeSelectionReply(&payload, self.alloc, id, status, history_rows, value) catch {
1088 payload.clearRetainingCapacity(); 1089 payload.clearRetainingCapacity();
1089 proto.encodeSelectionReply(&payload, self.alloc, id, .unavailable, "") catch return; 1090 proto.encodeSelectionReply(&payload, self.alloc, id, .unavailable, 0, "") catch return;
1090 _ = self.queueFrame(i, .selection_reply, payload.items); 1091 _ = self.queueFrame(i, .selection_reply, payload.items);
1091 return; 1092 return;
1092 }; 1093 };
@@ -1765,7 +1766,7 @@ pub const Server = struct {
1765 // timeout for an answer that was one line away. 1766 // timeout for an answer that was one line away.
1766 const req = proto.decodeSelectionReq(frame.payload) catch return; 1767 const req = proto.decodeSelectionReq(frame.payload) catch return;
1767 const si = self.clients[i].?.session orelse { 1768 const si = self.clients[i].?.session orelse {
1768 self.queueSelectionReply(i, req.id, selectionReplyStatus(null), ""); 1769 self.queueSelectionReply(i, req.id, selectionReplyStatus(null), 0, "");
1769 return; 1770 return;
1770 }; 1771 };
1771 var result = self.ses(si).eng.extractSelection( 1772 var result = self.ses(si).eng.extractSelection(
@@ -1776,20 +1777,25 @@ pub const Server = struct {
1776 req.active.col, 1777 req.active.col,
1777 proto.selection_text_max, 1778 proto.selection_text_max,
1778 ) catch { 1779 ) catch {
1779 self.queueSelectionReply(i, req.id, selectionReplyStatus(null), ""); 1780 // No extraction happened, so there is no screen whose
1781 // history this could honestly quote. Zero is the
1782 // watermark that claims nothing, and a non-ok status is
1783 // never compared against one anyway.
1784 self.queueSelectionReply(i, req.id, selectionReplyStatus(null), 0, "");
1780 return; 1785 return;
1781 }; 1786 };
1782 defer result.deinit(self.alloc); 1787 defer result.deinit(self.alloc);
1783 // Status and text are one invariant, not two independent 1788 // Status and text are one invariant, not two independent
1784 // fields. In particular, an impossible `.ok` without the 1789 // fields. In particular, an impossible `.ok` without the
1785 // owned text must not become a successful empty selection. 1790 // owned text must not become a successful empty selection.
1791 const base = result.history_rows;
1786 switch (result.status) { 1792 switch (result.status) {
1787 .ok => if (result.text) |value| 1793 .ok => if (result.text) |value|
1788 self.queueSelectionReply(i, req.id, selectionReplyStatus(.ok), value) 1794 self.queueSelectionReply(i, req.id, selectionReplyStatus(.ok), base, value)
1789 else 1795 else
1790 self.queueSelectionReply(i, req.id, selectionReplyStatus(null), ""), 1796 self.queueSelectionReply(i, req.id, selectionReplyStatus(null), base, ""),
1791 .invalid => self.queueSelectionReply(i, req.id, selectionReplyStatus(.invalid), ""), 1797 .invalid => self.queueSelectionReply(i, req.id, selectionReplyStatus(.invalid), base, ""),
1792 .too_large => self.queueSelectionReply(i, req.id, selectionReplyStatus(.too_large), ""), 1798 .too_large => self.queueSelectionReply(i, req.id, selectionReplyStatus(.too_large), base, ""),
1793 } 1799 }
1794 }, 1800 },
1795 .detach => self.dropClient(i), 1801 .detach => self.dropClient(i),
@@ -4352,7 +4358,7 @@ test "Server: an unencodable selection result still answers with unavailable" {
4352 }); 4358 });
4353 const real = srv.alloc; 4359 const real = srv.alloc;
4354 srv.alloc = failing.allocator(); 4360 srv.alloc = failing.allocator();
4355 srv.queueSelectionReply(0, 99, .ok, text); 4361 srv.queueSelectionReply(0, 99, .ok, 7, text);
4356 srv.alloc = real; 4362 srv.alloc = real;
4357 try std.testing.expect(failing.has_induced_failure); 4363 try std.testing.expect(failing.has_induced_failure);
4358 4364
src/wasm_core.zig
Old New
@@ -60,7 +60,12 @@ const Core = struct {
60 clipboard: client_core.ClipboardSet = .{ .target = 0, .base64 = &.{} }, 60 clipboard: client_core.ClipboardSet = .{ .target = 0, .base64 = &.{} },
61 /// Borrows from input_buf. The text is valid only until the host next 61 /// Borrows from input_buf. The text is valid only until the host next
62 /// stages or writes input, or starts another selection request. 62 /// stages or writes input, or starts another selection request.
63 selection: proto.SelectionReply = .{ .id = 0, .status = .unavailable, .text = &.{} }, 63 selection: proto.SelectionReply = .{
64 .id = 0,
65 .status = .unavailable,
66 .history_rows = 0,
67 .text = &.{},
68 },
64 /// Grid the readout buffers are sized for; follows rep.grid. 69 /// Grid the readout buffers are sized for; follows rep.grid.
65 cols: u16, 70 cols: u16,
66 rows: u16, 71 rows: u16,
@@ -99,7 +104,7 @@ const ClientAction = enum(i32) {
99 }; 104 };
100 105
101 fn clearSelectionResult(c: *Core) void { 106 fn clearSelectionResult(c: *Core) void {
102 c.selection = .{ .id = 0, .status = .unavailable, .text = &.{} }; 107 c.selection = .{ .id = 0, .status = .unavailable, .history_rows = 0, .text = &.{} };
103 } 108 }
104 109
105 fn clearBorrowedInputResults(c: *Core) void { 110 fn clearBorrowedInputResults(c: *Core) void {
@@ -347,6 +352,16 @@ export fn mux_selection_id() u32 {
347 return c.selection.id; 352 return c.selection.id;
348 } 353 }
349 354
355 /// Retained history rows the daemon sampled while extracting this reply.
356 /// The requester compares it with the value it sampled when the request
357 /// became authoritative: a LOWER reading means the page list evicted a
358 /// page, so every absolute row in the request now names a different line
359 /// and the text must be discarded rather than copied.
360 export fn mux_selection_history_rows() u32 {
361 const c = core orelse return 0;
362 return c.selection.history_rows;
363 }
364
350 export fn mux_selection_status() u32 { 365 export fn mux_selection_status() u32 {
351 const c = core orelse return @intFromEnum(proto.SelectionStatus.unavailable); 366 const c = core orelse return @intFromEnum(proto.SelectionStatus.unavailable);
352 return @intFromEnum(c.selection.status); 367 return @intFromEnum(c.selection.status);
web/mux.js
Old New
@@ -685,6 +685,18 @@ class Tile {
685 this.showSelectionUnavailable(); 685 this.showSelectionUnavailable();
686 return; 686 return;
687 } 687 }
688 // Ordinary output RAISES the daemon's history without renaming a single
689 // row — new rows are appended below the ones already there. Only a page
690 // eviction lowers it, and that renumbers every absolute row the request
691 // named, so a reply from below the sampled base is text belonging to
692 // lines the user never highlighted. Discarding it is the same outcome
693 // as any other unusable reply, so it takes the same path rather than
694 // inventing a second kind of failure.
695 if ((this.core.mux_selection_history_rows() >>> 0) < active.historyBase) {
696 this.selection.text = null;
697 this.showSelectionUnavailable();
698 return;
699 }
688 const ptr = this.core.mux_selection_ptr(); 700 const ptr = this.core.mux_selection_ptr();
689 const len = this.core.mux_selection_len(); 701 const len = this.core.mux_selection_len();
690 // Selection text borrows the staging buffer. Snapshot it before any 702 // Selection text borrows the staging buffer. Snapshot it before any
@@ -742,6 +754,12 @@ class Tile {
742 selection, 754 selection,
743 id: selection.requestId, 755 id: selection.requestId,
744 generation: this.selectionRequestVersion, 756 generation: this.selectionRequestVersion,
757 // The absolute rows in `selection` are counted from the oldest
758 // RETAINED history row, so they only name lines while that row keeps
759 // its identity. Sampled here, which is the last instant before the
760 // request becomes authoritative, and compared against the daemon's
761 // own reading in onSelectionReply.
762 historyBase: this.core.mux_history_rows() >>> 0,
745 }; 763 };
746 this.activeSelectionRequest = request; 764 this.activeSelectionRequest = request;
747 return request; 765 return request;
web/verify.js
Old New
@@ -769,7 +769,7 @@ async function verifySelectionShell(shell, html) {
769 const memory = { buffer: new ArrayBuffer(1024) }; 769 const memory = { buffer: new ArrayBuffer(1024) };
770 let requestResult = 16; 770 let requestResult = 16;
771 let scrollFeedResult = 0; 771 let scrollFeedResult = 0;
772 let result = { id: 0, status: 3, ptr: 96, len: 0 }; 772 let result = { id: 0, status: 3, historyRows: 30, ptr: 96, len: 0 };
773 const requestCalls = []; 773 const requestCalls = [];
774 selected.core = { 774 selected.core = {
775 memory, 775 memory,
@@ -794,6 +794,7 @@ async function verifySelectionShell(shell, html) {
794 mux_client_frame: (type) => type === 0x90 ? 4 : 0, 794 mux_client_frame: (type) => type === 0x90 ? 4 : 0,
795 mux_selection_id: () => result.id | 0, 795 mux_selection_id: () => result.id | 0,
796 mux_selection_status: () => result.status, 796 mux_selection_status: () => result.status,
797 mux_selection_history_rows: () => result.historyRows | 0,
797 mux_selection_ptr: () => result.ptr, 798 mux_selection_ptr: () => result.ptr,
798 mux_selection_len: () => result.len, 799 mux_selection_len: () => result.len,
799 mux_selection_request: (id, ar, ac, br, bc) => { 800 mux_selection_request: (id, ar, ac, br, bc) => {
@@ -822,10 +823,12 @@ async function verifySelectionShell(shell, html) {
822 tile: selected, memory, requestCalls, sent, 823 tile: selected, memory, requestCalls, sent,
823 setRequestResult(value) { requestResult = value; }, 824 setRequestResult(value) { requestResult = value; },
824 setScrollFeedResult(value) { scrollFeedResult = value; }, 825 setScrollFeedResult(value) { scrollFeedResult = value; },
825 setResult(id, status, textBytes) { 826 // The default history reading matches mux_history_rows above, so a
827 // test that does not care about the watermark never trips it.
828 setResult(id, status, textBytes, historyRows = 30) {
826 const bytes = Uint8Array.from(textBytes); 829 const bytes = Uint8Array.from(textBytes);
827 new Uint8Array(memory.buffer).set(bytes, 96); 830 new Uint8Array(memory.buffer).set(bytes, 96);
828 result = { id, status, ptr: 96, len: bytes.length }; 831 result = { id, status, historyRows, ptr: 96, len: bytes.length };
829 }, 832 },
830 reflows: () => reflows, 833 reflows: () => reflows,
831 }; 834 };
@@ -1669,7 +1672,7 @@ async function verifySelectionShell(shell, html) {
1669 }; 1672 };
1670 authorizeSelectionReply(routed); 1673 authorizeSelectionReply(routed);
1671 routed.setResult(77, 0, Buffer.from('routed')); 1674 routed.setResult(77, 0, Buffer.from('routed'));
1672 const replyPayload = Uint8Array.from([77, 0, 0, 0, 0, ...Buffer.from('routed')]); 1675 const replyPayload = Uint8Array.from([77, 0, 0, 0, 0, 30, 0, 0, 0, ...Buffer.from('routed')]);
1673 const replyEnvelope = new Uint8Array(6 + replyPayload.length); 1676 const replyEnvelope = new Uint8Array(6 + replyPayload.length);
1674 replyEnvelope[0] = 0; 1677 replyEnvelope[0] = 0;
1675 replyEnvelope[1] = 0x90; 1678 replyEnvelope[1] = 0x90;
@@ -1678,6 +1681,30 @@ async function verifySelectionShell(shell, html) {
1678 routed.tile.onMessage(replyEnvelope); 1681 routed.tile.onMessage(replyEnvelope);
1679 check('selection-reply frame routes through semantic client core', routed.tile.selection.text, 'routed'); 1682 check('selection-reply frame routes through semantic client core', routed.tile.selection.text, 'routed');
1680 1683
1684 const evicted = makeTile();
1685 evicted.tile.selection = {
1686 anchor: { row: 30, col: 0 }, active: { row: 31, col: 2 }, requestId: 82, text: null,
1687 };
1688 const evictedRequest = authorizeSelectionReply(evicted);
1689 check('selection request samples the history base its rows are counted from', evictedRequest.historyBase, 30);
1690 evicted.setResult(82, 0, Buffer.from('rows that moved under the request'), 25);
1691 evicted.tile.onSelectionReply();
1692 check('reply below the sampled history base retains no text', evicted.tile.selection.text, null);
1693 check('reply below the sampled history base reports unavailable', `${evicted.tile.copyButton.className}|${evicted.tile.copyButton.textContent}`, 'copy-request on error|Selection unavailable');
1694 check('reply below the sampled history base consumes the request', evicted.tile.activeSelectionRequest, null);
1695
1696 // The other half of the same rule, and the one a stricter comparison
1697 // would break: output APPENDS rows below the retained ones, so a higher
1698 // reading names the same lines the drag did.
1699 const grown = makeTile();
1700 grown.tile.selection = {
1701 anchor: { row: 30, col: 0 }, active: { row: 31, col: 2 }, requestId: 83, text: null,
1702 };
1703 authorizeSelectionReply(grown);
1704 grown.setResult(83, 0, Buffer.from('same rows, more below them'), 900);
1705 grown.tile.onSelectionReply();
1706 check('a risen history base leaves the reply usable', grown.tile.selection.text, 'same rows, more below them');
1707
1681 const blockedCopy = makeTile(); 1708 const blockedCopy = makeTile();
1682 blockedCopy.tile.pendingClipboard = 'unrelated OSC 52 text'; 1709 blockedCopy.tile.pendingClipboard = 'unrelated OSC 52 text';
1683 blockedCopy.tile.clipboardVersion = 1; 1710 blockedCopy.tile.clipboardVersion = 1;
@@ -2376,12 +2403,13 @@ async function main() {
2376 check('wide type clears clipboard len', e.mux_clipboard_len(), 0); 2403 check('wide type clears clipboard len', e.mux_clipboard_len(), 0);
2377 check('wide type clears clipboard target', e.mux_clipboard_target(), 0); 2404 check('wide type clears clipboard target', e.mux_clipboard_target(), 0);
2378 2405
2379 const selectionReply = (id, status, text = '') => { 2406 const selectionReply = (id, status, text = '', historyRows = 0) => {
2380 const body = Buffer.from(text, 'utf8'); 2407 const body = Buffer.from(text, 'utf8');
2381 const reply = Buffer.alloc(5 + body.length); 2408 const reply = Buffer.alloc(9 + body.length);
2382 reply.writeUInt32LE(id, 0); 2409 reply.writeUInt32LE(id, 0);
2383 reply[4] = status; 2410 reply[4] = status;
2384 body.copy(reply, 5); 2411 reply.writeUInt32LE(historyRows, 5);
2412 body.copy(reply, 9);
2385 return reply; 2413 return reply;
2386 }; 2414 };
2387 const selectionText = () => Buffer.from(mem().subarray( 2415 const selectionText = () => Buffer.from(mem().subarray(
@@ -2428,11 +2456,12 @@ async function main() {
2428 ); 2456 );
2429 check( 2457 check(
2430 'selection valid after malformed matching reply', 2458 'selection valid after malformed matching reply',
2431 e.mux_client_frame(0x90, stage(selectionReply(latestSelectionId, 0, 'selected 漢'))), 2459 e.mux_client_frame(0x90, stage(selectionReply(latestSelectionId, 0, 'selected 漢', 0xdeadbeef))),
2432 clientAction.selection, 2460 clientAction.selection,
2433 ); 2461 );
2434 check('selection getter id', e.mux_selection_id(), latestSelectionId); 2462 check('selection getter id', e.mux_selection_id(), latestSelectionId);
2435 check('selection getter status', e.mux_selection_status(), 0); 2463 check('selection getter status', e.mux_selection_status(), 0);
2464 check('selection getter history rows', e.mux_selection_history_rows() >>> 0, 0xdeadbeef);
2436 check('selection getter len', e.mux_selection_len(), Buffer.byteLength('selected 漢')); 2465 check('selection getter len', e.mux_selection_len(), Buffer.byteLength('selected 漢'));
2437 check('selection getter borrowed text', selectionText().toString('utf8'), 'selected 漢'); 2466 check('selection getter borrowed text', selectionText().toString('utf8'), 'selected 漢');
2438 check( 2467 check(
@@ -2667,9 +2696,10 @@ async function main() {
2667 check('dump starts', outBytes().toString('utf8').startsWith('漢字'), true); 2696 check('dump starts', outBytes().toString('utf8').startsWith('漢字'), true);
2668 2697
2669 // --- a real-sized snapshot fits the staging buffer --- 2698 // --- a real-sized snapshot fits the staging buffer ---
2670 // The staging cap is pinned to the largest selection reply: its five-byte 2699 // The staging cap is pinned to the largest selection reply: its nine-byte
2671 // correlation/status prefix plus the protocol's one-MiB text maximum. 2700 // correlation/status/history prefix plus the protocol's one-MiB text
2672 check('input cap', e.mux_input_cap(), 1024 * 1024 + 5); 2701 // maximum.
2702 check('input cap', e.mux_input_cap(), 1024 * 1024 + 9);
2673 2703
2674 // --- the shell's ACTUAL call list, read out of mux.js --- 2704 // --- the shell's ACTUAL call list, read out of mux.js ---
2675 // Everything above pins exports this file happens to name. This pins 2705 // Everything above pins exports this file happens to name. This pins