44feadf5
feat: pin selection replies to a history watermark
a73x 2026-08-18 12:27
Commit message
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 |