a73x

43667a46

refactor: one exhaustive frame switch, in the Core

a73x   2026-08-20 04:16

Commit message
refactor: one exhaustive frame switch, in the Core

client.zig routed frames by type and wallview.zig routed them again, with
their own subsets of the same enum — the twin phase 3a accepted so the
extraction could land in one piece. It had already drifted: a short
snapshot was a `continue` in one and a broken frame loop in the other.

`Core.frame` is the one switch now, the replica's apply included (a
driver that applied on its own would need to know which types the replica
takes, which is the same switch again). What comes back is only what is
LEFT: first state, a resync's re-attach spelling, and the three frames
that are the session's lifecycle rather than its content.

The banner goes with it — a paint is the Core's — and client.zig drops
its `paint` import entirely, which build.zig's module table then caught
as a stale grant.

build.zig
Old New
@@ -251,7 +251,7 @@ const mod_table = [_]ModSpec{
251 // attach records its own tile (the wall is attach history), and the 251 // attach records its own tile (the wall is attach history), and the
252 // chord switches that re-dial from inside client.attach have to record 252 // chord switches that re-dial from inside client.attach have to record
253 // theirs too, so the writer cannot live up in mux_main. 253 // theirs too, so the writer cannot live up in mux_main.
254 .{ .name = "client", .path = "src/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "interact", "quic_client", "quic", "handoff", "proxy", "paint", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 254 .{ .name = "client", .path = "src/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "interact", "quic_client", "quic", "handoff", "proxy", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
255 // The daemon entrypoint loads the key and constructs the listener, so 255 // The daemon entrypoint loads the key and constructs the listener, so
256 // it needs quic/quic_server directly rather than through the server. 256 // it needs quic/quic_server directly rather than through the server.
257 // `muxd endpoint` prints the announce line handoff spells; sockpath is 257 // `muxd endpoint` prints the announce line handoff spells; sockpath is
src/client.zig
Old New
@@ -29,10 +29,6 @@ const handoff = @import("handoff");
29 // The wall file: attach history. See `recordTile` for why the writer of a 29 // The wall file: attach history. See `recordTile` for why the writer of a
30 // user's tile is this module and not mux_main. 30 // user's tile is this module and not mux_main.
31 const wall = @import("wall"); 31 const wall = @import("wall");
32 // The [reconnecting] banner, and nothing else: painting the replica is
33 // interact's. Named `paint_mod` for the collision the name `paint` used
34 // to have with a local here.
35 const paint_mod = @import("paint");
36 // For ignoreSigpipe only, which proxy.zig owns. 32 // For ignoreSigpipe only, which proxy.zig owns.
37 const proxy = @import("proxy"); 33 const proxy = @import("proxy");
38 34
@@ -1542,6 +1538,10 @@ fn session(
1542 // Every route into here shares the reason (see dropScrollView), 1538 // Every route into here shares the reason (see dropScrollView),
1543 // so it is handled once. 1539 // so it is handled once.
1544 core.dropScrollView(); 1540 core.dropScrollView();
1541 // Said before the dial, not inside it: the banner is a PAINT on
1542 // the session's screen, and paints are the Core's — which is
1543 // what leaves this file with no painter of its own at all.
1544 core.banner("[reconnecting]");
1545 if (!reconnect( 1545 if (!reconnect(
1546 alloc, 1546 alloc,
1547 transport, 1547 transport,
@@ -1550,8 +1550,6 @@ fn session(
1550 core.rep.last_seq, 1550 core.rep.last_seq,
1551 core.rep.session_epoch, 1551 core.rep.session_epoch,
1552 core.in_fd, 1552 core.in_fd,
1553 core.out_fd,
1554 core.is_tty,
1555 session_name, 1553 session_name,
1556 )) { 1554 )) {
1557 // Ctrl-\ during a reconnect: the user is done waiting, but 1555 // Ctrl-\ during a reconnect: the user is done waiting, but
@@ -1646,35 +1644,38 @@ fn session(
1646 // `ownTerminal`; the one-line version is that this is what arms 1644 // `ownTerminal`; the one-line version is that this is what arms
1647 // the teardown, so nothing may write a mode before it. 1645 // the teardown, so nothing may write a mode before it.
1648 try core.ownTerminal(); 1646 try core.ownTerminal();
1649 switch (frame.type) { 1647 // Everything the frame means to the replica and the screen. What
1650 .snapshot => { 1648 // comes back is what is LEFT for this driver: the attach history
1651 // The Replica adopts seq/epoch/history, resizes to the 1649 // it writes on first state, the re-attach it spells on a resync,
1652 // prefix's grid, and rebuilds — even while scrolled, so 1650 // and the three frames that are the session's lifecycle rather
1653 // leaving scroll mode paints current state. A short 1651 // than its content.
1654 // snapshot proves nothing (BadPayload leaves everything 1652 const routed = core.frame(frame.type, frame.payload) catch |err| {
1655 // untouched, state_since_attach included); anything 1653 // The record survives a failing PAINT, and that ordering is
1656 // else out of the resize stays loud. 1654 // deliberate rather than incidental. Phase 3a kept
1657 _ = core.rep.apply(.snapshot, frame.payload) catch |err| switch (err) { 1655 // `recordOnState` between the replica's apply and the paint
1658 error.BadPayload => continue, 1656 // for exactly this: an allocation failure while drawing must
1659 else => |e| return e, 1657 // not cost a session the tile that says it was attached to.
1660 }; 1658 // `frame` collapsed apply and paint into one call, so the
1661 // The attach landed. Recorded at the two `apply` sites 1659 // guarantee moves here — `apply` marks state before anything
1662 // rather than once per poll turn because either kind of 1660 // can paint, so a Core that threw on the way out has already
1663 // state proves it, and a shell that exits in the same 1661 // told the replica, and `recordOnState` reads that flag.
1664 // read as its snapshot must not lose its tile to a loop 1662 recordOnState(&tile_recorded, &core.rep, alloc, target, session_name, wall_warned);
1665 // turn that never comes. 1663 return err;
1664 };
1665 switch (routed) {
1666 // A frame that changed nothing — a short snapshot the
1667 // replica left untouched, a history page for a view that is
1668 // already live again.
1669 .skip => continue,
1670 .handled => {},
1671 .state, .resync => {
1672 // The attach landed. Recorded on either kind of state,
1673 // and BEFORE the resync branch leaves this arm, because
1674 // a shell that exits in the same read as its snapshot
1675 // must not lose its tile to a loop turn that never comes.
1666 recordOnState(&tile_recorded, &core.rep, alloc, target, session_name, wall_warned); 1676 recordOnState(&tile_recorded, &core.rep, alloc, target, session_name, wall_warned);
1667 reconnect_grace_until = null; 1677 reconnect_grace_until = null;
1668 try core.snapshotTaken(); 1678 if (routed == .resync) {
1669 },
1670 .delta => {
1671 reconnect_grace_until = null;
1672 const applied = try core.rep.apply(.delta, frame.payload);
1673 // Bound before the `.resync` branch, which can leave
1674 // this arm: a delta the replica accepted is state, and
1675 // state is what makes the attach history.
1676 recordOnState(&tile_recorded, &core.rep, alloc, target, session_name, wall_warned);
1677 if (applied == .resync) {
1678 // A rejected delta means the replica can no longer be 1679 // A rejected delta means the replica can no longer be
1679 // trusted; ask for a fresh snapshot rather than 1680 // trusted; ask for a fresh snapshot rather than
1680 // silently skipping it and desyncing for good. By 1681 // silently skipping it and desyncing for good. By
@@ -1690,91 +1691,85 @@ fn session(
1690 sendAttach(transport, core.size, 0, 0, session_name) catch {}; 1691 sendAttach(transport, core.size, 0, 0, session_name) catch {};
1691 continue; 1692 continue;
1692 } 1693 }
1693 try core.deltaTaken(frame.payload);
1694 },
1695 .pty_mode => switch (try core.ptyModeChanged(frame.payload)) {
1696 .skip => continue,
1697 .carry_on => {},
1698 }, 1694 },
1699 .scrollback_chunk => switch (try core.scrollbackPage(frame.payload)) { 1695 .not_mine => switch (frame.type) {
1700 .skip => continue, 1696 .exit_status => {
1701 .carry_on => {}, 1697 // Before any session state, exit_status is almost always
1702 }, 1698 // the daemon refusing the attach — say so, or it looks
1703 .term_event, .term_modes => try core.semanticFrame(frame.type, frame.payload), 1699 // exactly like the shell itself exiting non-zero.
1704 .term_title => try core.titleFrame(frame.payload), 1700 if (!core.rep.state_since_attach) {
1705 .exit_status => { 1701 // Right after a reconnect the usual cause is the
1706 // Before any session state, exit_status is almost always 1702 // daemon not having reaped our dead predecessor's
1707 // the daemon refusing the attach — say so, or it looks 1703 // slot yet, so the session is full of *us*. Worth a
1708 // exactly like the shell itself exiting non-zero. 1704 // few seconds of retrying rather than an exit the
1709 if (!core.rep.state_since_attach) { 1705 // user has to undo by hand.
1710 // Right after a reconnect the usual cause is the 1706 if (reconnect_grace_until) |until| {
1711 // daemon not having reaped our dead predecessor's 1707 if (std.time.milliTimestamp() < until) {
1712 // slot yet, so the session is full of *us*. Worth a 1708 needs_reconnect = true;
1713 // few seconds of retrying rather than an exit the 1709 continue;
1714 // user has to undo by hand. 1710 }
1715 if (reconnect_grace_until) |until| {
1716 if (std.time.milliTimestamp() < until) {
1717 needs_reconnect = true;
1718 continue;
1719 } 1711 }
1712 // A switch's arrival is the one refusal with an
1713 // answer better than exiting: the daemon would not
1714 // create the session (its table is full, at four),
1715 // and the one we detached from is still there.
1716 if (arrived_from_switch) return .refused;
1717 exit_msg = "mux: attach refused or no state received (session full?)";
1718 return .{ .exit = 1 };
1720 } 1719 }
1721 // A switch's arrival is the one refusal with an 1720 // After state, this is the shell itself exiting — even
1722 // answer better than exiting: the daemon would not 1721 // if we reconnected moments ago. Never retried.
1723 // create the session (its table is full, at four), 1722 return .{ .exit = if (frame.payload.len >= 1) frame.payload[0] else 0 };
1724 // and the one we detached from is still there. 1723 },
1725 if (arrived_from_switch) return .refused; 1724 .taken_over => {
1726 exit_msg = "mux: attach refused or no state received (session full?)"; 1725 exit_msg = "mux: detached (another client attached)";
1727 return .{ .exit = 1 }; 1726 return .{ .exit = 0 };
1728 } 1727 },
1729 // After state, this is the shell itself exiting — even 1728 .sessions_reply => {
1730 // if we reconnected moments ago. Never retried. 1729 // Gated: only a chord this client typed may move the
1731 return .{ .exit = if (frame.payload.len >= 1) frame.payload[0] else 0 }; 1730 // user off their session, so an unasked-for reply is
1732 }, 1731 // ignored rather than acted on. The question is asked
1733 .taken_over => { 1732 // once and answered once either way, so the intent is
1734 exit_msg = "mux: detached (another client attached)"; 1733 // spent here whichever name it picks.
1735 return .{ .exit = 0 }; 1734 const intent = pending_switch.take();
1736 }, 1735 var name_buf: [proto.session_name_max]u8 = undefined;
1737 .sessions_reply => { 1736 // An OPTIONAL ACTION, not an optional name: `.wall`
1738 // Gated: only a chord this client typed may move the 1737 // ends this run without naming a session to go to, so
1739 // user off their session, so an unasked-for reply is 1738 // the answer a chord reads out of the reply is the
1740 // ignored rather than acted on. The question is asked 1739 // Outcome itself. Null still means "stay put".
1741 // once and answered once either way, so the intent is 1740 const leave: ?Outcome = switch (intent) {
1742 // spent here whichever name it picks. 1741 .none => null,
1743 const intent = pending_switch.take(); 1742 .new, .next, .prev => blk: {
1744 var name_buf: [proto.session_name_max]u8 = undefined; 1743 const pick: ?[]const u8 = switch (intent) {
1745 // An OPTIONAL ACTION, not an optional name: `.wall` 1744 .new => nextFreeName(&name_buf, frame.payload),
1746 // ends this run without naming a session to go to, so 1745 .next => ringNeighbour(frame.payload, session_name, .next),
1747 // the answer a chord reads out of the reply is the 1746 .prev => ringNeighbour(frame.payload, session_name, .prev),
1748 // Outcome itself. Null still means "stay put". 1747 else => unreachable,
1749 const leave: ?Outcome = switch (intent) { 1748 };
1750 .none => null, 1749 break :blk switchTarget(pick);
1751 .new, .next, .prev => blk: { 1750 },
1752 const pick: ?[]const u8 = switch (intent) { 1751 // A reply too long to carry is a wall not shown,
1753 .new => nextFreeName(&name_buf, frame.payload), 1752 // never a truncated one — see SessionsText.
1754 .next => ringNeighbour(frame.payload, session_name, .next), 1753 .wall => if (SessionsText.of(frame.payload)) |t|
1755 .prev => ringNeighbour(frame.payload, session_name, .prev), 1754 Outcome{ .wall = t }
1756 else => unreachable, 1755 else
1757 }; 1756 null,
1758 break :blk switchTarget(pick); 1757 };
1759 }, 1758 if (leave) |out| {
1760 // A reply too long to carry is a wall not shown, 1759 // Detach rather than just dropping the link: the
1761 // never a truncated one — see SessionsText. 1760 // slot this client holds in the daemon is freed
1762 .wall => if (SessionsText.of(frame.payload)) |t| 1761 // now, not whenever the socket's death is noticed —
1763 Outcome{ .wall = t } 1762 // and whatever follows (an attach, or the wall's
1764 else 1763 // own tiles) is about to ask for one back.
1765 null, 1764 transport.writeFrame(.detach, "") catch {};
1766 }; 1765 return out;
1767 if (leave) |out| { 1766 }
1768 // Detach rather than just dropping the link: the 1767 },
1769 // slot this client holds in the daemon is freed 1768 // `.not_mine` names exactly these three (see
1770 // now, not whenever the socket's death is noticed — 1769 // interact.Routed); MsgType is an open enum, so the
1771 // and whatever follows (an attach, or the wall's 1770 // compiler still wants an arm for the rest.
1772 // own tiles) is about to ask for one back. 1771 else => {},
1773 transport.writeFrame(.detach, "") catch {};
1774 return out;
1775 }
1776 }, 1772 },
1777 else => {},
1778 } 1773 }
1779 } 1774 }
1780 1775
@@ -1887,13 +1882,10 @@ fn reconnect(
1887 last_seq: u64, 1882 last_seq: u64,
1888 session_epoch: u64, 1883 session_epoch: u64,
1889 stdin_fd: std.posix.fd_t, 1884 stdin_fd: std.posix.fd_t,
1890 stdout_fd: std.posix.fd_t,
1891 is_tty: bool,
1892 /// Same session this whole run has been attaching to — a reconnect must 1885 /// Same session this whole run has been attaching to — a reconnect must
1893 /// ask for it again, not fall back to the default. 1886 /// ask for it again, not fall back to the default.
1894 session_name: []const u8, 1887 session_name: []const u8,
1895 ) bool { 1888 ) bool {
1896 if (is_tty) paint_mod.paintBanner(stdout_fd, size, "[reconnecting]");
1897 // The dead transport is released exactly once, here. Everything after 1889 // The dead transport is released exactly once, here. Everything after
1898 // this point owns only what it opened itself, which is what keeps the 1890 // this point owns only what it opened itself, which is what keeps the
1899 // retry loop from closing the same fd twice. 1891 // retry loop from closing the same fd twice.
src/interact.zig
Old New
@@ -240,7 +240,13 @@ fn onWinch(_: c_int) callconv(.c) void {
240 winch_flag.store(true, .release); 240 winch_flag.store(true, .release);
241 } 241 }
242 242
243 fn ttySize(fd: std.posix.fd_t) ?proto.Size { 243 /// This terminal's size, or null when there is no terminal to measure.
244 ///
245 /// Public because a driver that lays a screen out BEFORE it has a Core to
246 /// measure it needs the same answer: the CLI wall cuts its stripes from the
247 /// terminal at startup, and a second copy of this would be a twin that
248 /// drifts on exactly the 0x0 case below.
249 pub fn ttySize(fd: std.posix.fd_t) ?proto.Size {
244 if (!std.posix.isatty(fd)) return null; 250 if (!std.posix.isatty(fd)) return null;
245 var ws: std.posix.winsize = undefined; 251 var ws: std.posix.winsize = undefined;
246 if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; 252 if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null;
@@ -262,7 +268,7 @@ fn ttySize(fd: std.posix.fd_t) ?proto.Size {
262 /// half of a pair — `terminal_teardown` undoes each of these, and the pair 268 /// half of a pair — `terminal_teardown` undoes each of these, and the pair
263 /// is pinned together in one test so neither half can drift alone. 269 /// is pinned together in one test so neither half can drift alone.
264 /// 270 ///
265 /// Written exactly once per process, under the same `alt_screen` gate that 271 /// Written exactly once per process, under the same `claim` gate that
266 /// admits the teardown; the argument for that gate is at the call site. 272 /// admits the teardown; the argument for that gate is at the call site.
267 /// 273 ///
268 /// The mouse enables are last and they are the client's OWN: with no mouse 274 /// The mouse enables are last and they are the client's OWN: with no mouse
@@ -273,7 +279,26 @@ fn ttySize(fd: std.posix.fd_t) ?proto.Size {
273 /// all, and it turns 1007's synthesis off as a side effect. The session's 279 /// all, and it turns 1007's synthesis off as a side effect. The session's
274 /// own modes arrive moments later in the first `term_modes` and level-set 280 /// own modes arrive moments later in the first `term_modes` and level-set
275 /// these; this is what the wheel does until they do. 281 /// these; this is what the wheel does until they do.
276 const terminal_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++ client_mouse_setup; 282 const terminal_setup = terminal_frame_setup ++ session_claim;
283
284 /// The half of `terminal_setup` that is about owning a SCREEN — the title
285 /// stack, the alternate screen, the cursor, autowrap. A driver that already
286 /// holds a screen and lends it to one session at a time writes this once
287 /// for its own lifetime and `session_claim` per promoted session; see
288 /// `wall_setup`.
289 const terminal_frame_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l";
290
291 /// The half that is about holding a SESSION on somebody's terminal: the
292 /// mouse modes its wheel is read out of, and nothing else. A wall tile's
293 /// promote writes exactly this (`Core.claimTerminal`) — the wall owns the
294 /// screen for its whole life and a tile may not touch it.
295 ///
296 /// Spelled as a shared constant rather than repeated, so the pairing is
297 /// structural: `terminal_setup` and `wall_setup` are both built from these
298 /// pieces, and `session_release` appears verbatim inside every teardown.
299 /// A mouse mode added to one claim is added to all of them, and one dropped
300 /// from the release is dropped from every path that undoes a claim.
301 const session_claim = client_mouse_setup;
277 302
278 /// The mouse modes the client asks its own terminal for when no application 303 /// The mouse modes the client asks its own terminal for when no application
279 /// in the session wants them: button presses (1000) reported in SGR (1006). 304 /// in the session wants them: button presses (1000) reported in SGR (1006).
@@ -337,7 +362,36 @@ fn inClientCapture(comptime dec: u16) bool {
337 /// terminal without the stack ignores both halves, which costs a title bar 362 /// terminal without the stack ignores both halves, which costs a title bar
338 /// left showing what the session set — the tmux behaviour, and the 363 /// left showing what the session set — the tmux behaviour, and the
339 /// fallback this would otherwise have shipped as. 364 /// fallback this would otherwise have shipped as.
340 const terminal_teardown = "\x1b[?2004l" ++ mouse_teardown ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l"; 365 const terminal_teardown = session_release ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l";
366
367 /// Undoes `session_claim`, and everything a SESSION can have asked this
368 /// terminal for while it held it: `?2004l` because the session armed
369 /// bracketed paste, the whole mouse table because the session's own modes
370 /// were mirrored onto this terminal (`appendMouseModes`) and a promoted
371 /// tile's application can have asked for modes the client never wanted.
372 ///
373 /// It leads `terminal_teardown` — the exit path — and it IS the demote
374 /// path (`Core.releaseTerminal`) and the wall's exit path
375 /// (`wall_teardown`). Every route out of a terminal claim writes these
376 /// bytes; that is the pairing, and one test pins all four.
377 const session_release = "\x1b[?2004l" ++ mouse_teardown;
378
379 /// What a driver that lends its screen to one session at a time writes on
380 /// the way in and on the way out. The CLI wall is the driver: it holds the
381 /// alternate screen for its whole life while its tiles promote and demote
382 /// underneath it, so the screen half is written once here and never by a
383 /// tile.
384 ///
385 /// The teardown is `terminal_teardown` itself, and that identity is the
386 /// design rather than a coincidence. The wall can be left while a tile is
387 /// still PROMOTED — stdin closes under a zoom, and `q` is not reachable
388 /// from inside one — so every mode a session set through that tile
389 /// (bracketed paste, the mouse modes it asked for, the window title it
390 /// pushed) has to come off here as well as at the demote. A wall exit and a
391 /// client exit are the same terminal, restored the same way; the only
392 /// difference is that the wall wrote the screen half once for N sessions.
393 pub const wall_setup = terminal_frame_setup ++ "\x1b[H\x1b[2J";
394 pub const wall_teardown = terminal_teardown;
341 395
342 /// Every mouse mode this client can ever have turned on, off. Built from 396 /// Every mouse mode this client can ever have turned on, off. Built from
343 /// the wire table rather than typed out, because the set it has to undo is 397 /// the wire table rather than typed out, because the set it has to undo is
@@ -476,7 +530,7 @@ fn appendTermTitle(
476 /// buffer is a refusal, and half an escape sequence on a real tty paints 530 /// buffer is a refusal, and half an escape sequence on a real tty paints
477 /// garbage the user has to clear. 531 /// garbage the user has to clear.
478 /// 532 ///
479 /// `owns_terminal` is the caller's `alt_screen`, and it gates every channel 533 /// `owns_terminal` is the caller's `Core.claim`, and it gates every channel
480 /// rather than any one of them. mux writes a side channel only once it has 534 /// rather than any one of them. mux writes a side channel only once it has
481 /// taken the terminal over, because taking it over is also what arms the 535 /// taken the terminal over, because taking it over is also what arms the
482 /// teardown that puts it back: the title pop, and the `?2004l` for a 536 /// teardown that puts it back: the title pop, and the `?2004l` for a
@@ -486,7 +540,7 @@ fn appendTermTitle(
486 /// STDIN — it gates raw mode and the alt-screen entry, both of which are 540 /// STDIN — it gates raw mode and the alt-screen entry, both of which are
487 /// about input — while these writes go to STDOUT. With stdin redirected 541 /// about input — while these writes go to STDOUT. With stdin redirected
488 /// and stdout still a terminal (`echo x | mux`, `mux < /dev/null` typed at 542 /// and stdout still a terminal (`echo x | mux`, `mux < /dev/null` typed at
489 /// a prompt) `alt_screen` never becomes true, so mux would set the user's 543 /// a prompt) `claim` never leaves `.none`, so mux would set the user's
490 /// title and never pop it, and turn bracketed paste on and never turn it 544 /// title and never pop it, and turn bracketed paste on and never turn it
491 /// off. Every other side channel had the same shape; only the title made 545 /// off. Every other side channel had the same shape; only the title made
492 /// it a broken promise, because the title is the one mux justified by 546 /// it a broken promise, because the title is the one mux justified by
@@ -753,6 +807,79 @@ pub const Step = enum { ok, lost };
753 /// the session loop wrote before this was a return value. 807 /// the session loop wrote before this was a return value.
754 pub const Pass = enum { carry_on, skip }; 808 pub const Pass = enum { carry_on, skip };
755 809
810 /// What one frame turned out to be, once the Core has done its half of it.
811 ///
812 /// The exhaustive switch over `proto.MsgType` lives in `Core.frame` and
813 /// nowhere else; this is the narrow set of answers a driver still has to
814 /// act on. Two drivers wrote that switch between them before this existed
815 /// and they had already drifted (a short snapshot was a `continue` in one
816 /// and a broken frame loop in the other).
817 pub const Routed = enum {
818 /// Done here; the driver's pass carries on.
819 handled,
820 /// Done here, and it changed nothing — the driver's `continue`.
821 skip,
822 /// The replica took state from it: a snapshot, or a delta it accepted.
823 /// Named apart from `handled` because both drivers hang their own
824 /// bookkeeping off the first state of an attach — the CLI writes its
825 /// wall tile, a wall tile narrates `[up]` on its label bar.
826 state,
827 /// The replica took state and then refused what arrived, and can no
828 /// longer be trusted. Both drivers answer by re-attaching from scratch
829 /// (quoting no seq — the whole problem is that what we hold is
830 /// untrusted), but with their own attach frame: a client quotes its
831 /// size and session name, a tile quotes whatever its zoom claims.
832 resync,
833 /// Not the Core's. `exit_status` and `taken_over` are the session's
834 /// LIFECYCLE and `sessions_reply` is the daemon's session list; what
835 /// each MEANS is entirely the driver's — an exit ends a client and
836 /// merely relabels a tile — so the Core names them and stops.
837 not_mine,
838 };
839
840 /// Where a Core's paints are allowed to land, and who says so.
841 ///
842 /// A plain client never asks: its terminal is its own for the whole run, so
843 /// the default answers yes and holds nothing. A wall tile's terminal is
844 /// shared with N stripes on N threads and, while the tile is DEMOTED, is
845 /// not the tile's at all — the wall paints its stripe instead, cropped from
846 /// this same replica. So every paint asks first, and the answer is held
847 /// until the paint is finished: a zoom that moved between the decision and
848 /// the bytes would put one session's rows on another session's screen.
849 ///
850 /// Two function pointers rather than a driver interface because there is
851 /// exactly one question — "may I write to `out_fd` now, and will that stay
852 /// true until I say I am done" — and the wall already had the answer
853 /// (`paint_mu` plus its zoom check) before this Core existed.
854 ///
855 /// It gates PAINTS, not side channels. A mode or a title is a write whose
856 /// meaning does not depend on where the cursor is, and it is already gated
857 /// on the terminal claim (`Core.claim`), which a demoted tile does not
858 /// hold.
859 pub const Sink = struct {
860 ctx: ?*anyopaque = null,
861 /// True when the Core may paint, with whatever lock makes that true
862 /// now held. Null means "always, holding nothing".
863 begin: ?*const fn (?*anyopaque) bool = null,
864 /// Releases what `begin` took. Called only after a `begin` said true.
865 end: ?*const fn (?*anyopaque) void = null,
866 };
867
868 /// Which claim a Core holds on its terminal, and therefore which teardown
869 /// puts it back.
870 ///
871 /// A client OWNS its terminal for its whole run: the alternate screen, the
872 /// cursor, autowrap, the title stack. A wall tile BORROWS one the wall
873 /// already owns, so its claim is only what a SESSION brings — the mouse
874 /// modes, and whatever the session then asks this terminal for.
875 ///
876 /// It is also the flag that arms the undo, which is the load-bearing part:
877 /// nothing may write a mode to a terminal this is `.none` on, because
878 /// `.none` is exactly the state in which nothing is arranged to unset it.
879 /// See `ownTerminal` for the whole argument — it is one flag, not an
880 /// ordering, and it was a race before it was a flag.
881 pub const Claim = enum { none, whole, session };
882
756 /// One session's interaction state, and everything done to a terminal on 883 /// One session's interaction state, and everything done to a terminal on
757 /// its behalf: the replica the daemon's frames are replayed into, the 884 /// its behalf: the replica the daemon's frames are replayed into, the
758 /// prediction overlay drawn on top of it, the chord and mouse filters that 885 /// prediction overlay drawn on top of it, the chord and mouse filters that
@@ -766,16 +893,19 @@ pub const Pass = enum { carry_on, skip };
766 /// back, terminal first so anything the driver prints afterwards lands 893 /// back, terminal first so anything the driver prints afterwards lands
767 /// on the normal screen. 894 /// on the normal screen.
768 /// * `takeTerminal` once, then `ownTerminal` before handling ANY frame. 895 /// * `takeTerminal` once, then `ownTerminal` before handling ANY frame.
896 /// A driver that already owns the screen and lends it to one session at
897 /// a time uses `claimTerminal`/`releaseTerminal` instead — see `Claim`.
769 /// * per pass: `winch`, then `idle`. 898 /// * per pass: `winch`, then `idle`.
770 /// * per frame: the driver routes by type. `rep.apply` is the driver's 899 /// * per frame: `frame(type, payload)` routes it and returns what is
771 /// call because the attach's own bookkeeping is interleaved with it; 900 /// LEFT (see `Routed`) — the replica's apply included, because a driver
772 /// everything the frame then means to the screen is `snapshotTaken`, 901 /// that applied on its own would be a second exhaustive switch over the
773 /// `deltaTaken`, `ptyModeChanged`, `scrollbackPage`, `semanticFrame`, 902 /// wire, which is what this replaced.
774 /// `titleFrame`. The frames the Core does not name — exit_status,
775 /// taken_over, sessions_reply — are the session's LIFECYCLE and belong
776 /// entirely to the driver.
777 /// * per read of stdin: `readTyped`, the driver acts on the action, then 903 /// * per read of stdin: `readTyped`, the driver acts on the action, then
778 /// `forward` sends what is left. 904 /// `forward` sends what is left. A driver whose keyboard is on another
905 /// thread (the wall) skips `readTyped` and calls `forward` with what
906 /// that thread handed over — the chord layer is the same table either
907 /// way, and everything below it (mouse, wheel, scrollback, prediction)
908 /// is in `forward`.
779 /// * around a reconnect: `dropScrollView` before, `reattached` after. 909 /// * around a reconnect: `dropScrollView` before, `reattached` after.
780 /// 910 ///
781 /// What it depends on: an Engine, a Replica, the prediction overlay, the 911 /// What it depends on: an Engine, a Replica, the prediction overlay, the
@@ -783,10 +913,11 @@ pub const Pass = enum { carry_on, skip };
783 /// method that writes takes the transport as `anytype` and calls exactly 913 /// method that writes takes the transport as `anytype` and calls exactly
784 /// one thing on it, `writeFrame`. 914 /// one thing on it, `writeFrame`.
785 /// 915 ///
786 /// Nothing here is a singleton. A wall tile that promotes into this core 916 /// Nothing here is a singleton. A wall tile brings its own transport and
787 /// brings its own transport and its own Core; `out_fd`/`size` are the only 917 /// its own Core, one per tile from birth — there is never a second replica
788 /// two things that say where a Core paints, which is where a tile's 918 /// or a second applier for one tile. `out_fd`/`size` say where a Core
789 /// narrower paint sink will attach. 919 /// paints and `sink` says whether it may right now; a demoted tile's says
920 /// no, and the wall paints the stripe from `grid()` instead.
790 pub const Core = struct { 921 pub const Core = struct {
791 alloc: std.mem.Allocator, 922 alloc: std.mem.Allocator,
792 /// The user's terminal. `in_fd` is the descriptor the driver polls and 923 /// The user's terminal. `in_fd` is the descriptor the driver polls and
@@ -827,9 +958,12 @@ pub const Core = struct {
827 /// View state, not replay state, so it stays here rather than in the 958 /// View state, not replay state, so it stays here rather than in the
828 /// Replica (which holds grid/seq/epoch/history — see replica.zig). 959 /// Replica (which holds grid/seq/epoch/history — see replica.zig).
829 scroll_rows: u32 = 0, 960 scroll_rows: u32 = 0,
830 /// Whether the alternate screen was entered — which is also the flag 961 /// What this Core holds on the terminal, and therefore what it may
831 /// that says this Core took the terminal over. See `ownTerminal`. 962 /// write there and what it must undo. See `Claim` and `ownTerminal`.
832 alt_screen: bool = false, 963 claim: Claim = .none,
964 /// Whether this Core may paint right now, and what to hold while it
965 /// does. Set by a driver that shares its terminal; see `Sink`.
966 sink: Sink = .{},
833 /// Null when there was no terminal to put into raw mode. 967 /// Null when there was no terminal to put into raw mode.
834 orig_termios: ?std.posix.termios = null, 968 orig_termios: ?std.posix.termios = null,
835 /// The first paint after a reconnect must be a full one, so the 969 /// The first paint after a reconnect must be a full one, so the
@@ -848,7 +982,23 @@ pub const Core = struct {
848 in_fd: std.posix.fd_t, 982 in_fd: std.posix.fd_t,
849 out_fd: std.posix.fd_t, 983 out_fd: std.posix.fd_t,
850 ) !Core { 984 ) !Core {
851 const size = ttySize(out_fd) orelse proto.Size{ .cols = 80, .rows = 24 }; 985 return initSized(alloc, in_fd, out_fd, ttySize(out_fd) orelse .{ .cols = 80, .rows = 24 });
986 }
987
988 /// The same, at a size the driver already measured.
989 ///
990 /// For a driver whose layout was cut from ONE reading of the terminal —
991 /// the wall measures once at startup and has no SIGWINCH handler — so
992 /// that its tiles clip to the size its stripes were cut from, rather
993 /// than to whatever a second ioctl says after the user dragged a corner.
994 /// Two answers to "how big is this terminal" inside one screen is a
995 /// promoted tile painting at rows the wall does not believe in.
996 pub fn initSized(
997 alloc: std.mem.Allocator,
998 in_fd: std.posix.fd_t,
999 out_fd: std.posix.fd_t,
1000 size: proto.Size,
1001 ) !Core {
852 const eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); 1002 const eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows });
853 return .{ 1003 return .{
854 .alloc = alloc, 1004 .alloc = alloc,
@@ -865,9 +1015,11 @@ pub const Core = struct {
865 /// driver prints on its way out land on the normal screen rather than 1015 /// driver prints on its way out land on the normal screen rather than
866 /// the wiped alternate one. 1016 /// the wiped alternate one.
867 pub fn deinit(self: *Core) void { 1017 pub fn deinit(self: *Core) void {
868 // Only undo what was actually done: leaving the alternate screen we 1018 // Only undo what was actually claimed: leaving the alternate screen
869 // never entered would wipe the user's own scrollback. 1019 // we never entered would wipe the user's own scrollback, and a wall
870 if (self.alt_screen) proto.writeAllFd(self.out_fd, terminal_teardown) catch {}; 1020 // tile never entered it at all — the wall is still on it, and still
1021 // using it, after this tile's pump has gone.
1022 self.releaseTerminal();
871 if (self.orig_termios) |t| std.posix.tcsetattr(self.in_fd, .FLUSH, t) catch {}; 1023 if (self.orig_termios) |t| std.posix.tcsetattr(self.in_fd, .FLUSH, t) catch {};
872 dumpPredictStats(self.overlay.counters); 1024 dumpPredictStats(self.overlay.counters);
873 self.overlay.deinit(); 1025 self.overlay.deinit();
@@ -906,10 +1058,10 @@ pub const Core = struct {
906 /// must clip at the right edge rather than wrap and shift the whole paint. 1058 /// must clip at the right edge rather than wrap and shift the whole paint.
907 /// 1059 ///
908 /// The driver must call this AHEAD of routing a frame, and for EVERY frame 1060 /// The driver must call this AHEAD of routing a frame, and for EVERY frame
909 /// type, not just the ones that paint. `alt_screen` gates the exit 1061 /// type, not just the ones that paint. `claim` gates the exit
910 /// teardown, which is the only `?2004l` mux is certain to write — a session 1062 /// teardown, which is the only `?2004l` mux is certain to write — a session
911 /// that asks for bracketed paste and then dies never sends the term_modes 1063 /// that asks for bracketed paste and then dies never sends the term_modes
912 /// frame that would unset it. So a term_modes handled while `alt_screen` 1064 /// frame that would unset it. So a term_modes handled while `claim`
913 /// was still false would turn bracketing ON with nothing arranged to turn 1065 /// was still false would turn bracketing ON with nothing arranged to turn
914 /// it off — the user gets their terminal back still bracketing pastes long 1066 /// it off — the user gets their terminal back still bracketing pastes long
915 /// after mux exited, with nothing on screen to say why. 1067 /// after mux exited, with nothing on screen to say why.
@@ -927,12 +1079,12 @@ pub const Core = struct {
927 /// 1079 ///
928 /// Read it as two claims, because only the first is about ordering. (1) 1080 /// Read it as two claims, because only the first is about ordering. (1)
929 /// This runs before any arm can write `?2004h`. (2) `?2004l` is written 1081 /// This runs before any arm can write `?2004h`. (2) `?2004l` is written
930 /// only under `alt_screen`, so a `?2004h` written outside it is one nothing 1082 /// only under a `claim`, so a `?2004h` written outside one is nothing
931 /// will undo. 1083 /// will undo.
932 /// 1084 ///
933 /// Claim (2) used to be held by a race — `?2004h` went out unconditionally, 1085 /// Claim (2) used to be held by a race — `?2004h` went out unconditionally,
934 /// and on a tty this block simply always won. It is now structural: 1086 /// and on a tty this block simply always won. It is now structural:
935 /// `writeSideChannel` takes `alt_screen` and refuses everything while it is 1087 /// `writeSideChannel` takes the claim and refuses everything while it is
936 /// false, so the set and its undo are gated on one flag rather than on an 1088 /// false, so the set and its undo are gated on one flag rather than on an
937 /// ordering. That closed a hole the race left open, and the title is how it 1089 /// ordering. That closed a hole the race left open, and the title is how it
938 /// was found — see that function. A `--no-altscreen` or an inline mode 1090 /// was found — see that function. A `--no-altscreen` or an inline mode
@@ -942,7 +1094,7 @@ pub const Core = struct {
942 /// The title push (`22;0t`) rides the same gate for the same pairing 1094 /// The title push (`22;0t`) rides the same gate for the same pairing
943 /// argument, and needs it more: an unmatched POP does not restore a title, 1095 /// argument, and needs it more: an unmatched POP does not restore a title,
944 /// it pops whatever the terminal had underneath — somebody else's. Pushed 1096 /// it pops whatever the terminal had underneath — somebody else's. Pushed
945 /// here and popped in `terminal_teardown`, both under `alt_screen`, is what 1097 /// here and popped in `terminal_teardown`, both under `claim`, is what
946 /// makes the pair exactly one deep. `0` is "icon name and window title", 1098 /// makes the pair exactly one deep. `0` is "icon name and window title",
947 /// matching the OSC 0 `appendTermTitle` writes. 1099 /// matching the OSC 0 `appendTermTitle` writes.
948 /// 1100 ///
@@ -950,23 +1102,120 @@ pub const Core = struct {
950 /// checked rather than assumed: this write and the pop are the only two in 1102 /// checked rather than assumed: this write and the pop are the only two in
951 /// the tree, every exit from a session is a `return` (no process.exit, no 1103 /// the tree, every exit from a session is a `return` (no process.exit, no
952 /// exec, no panic here), and the reconnect path cannot push twice because 1104 /// exec, no panic here), and the reconnect path cannot push twice because
953 /// it re-enters the loop with `alt_screen` already true. What escapes is a 1105 /// it re-enters the loop with `claim` already set. What escapes is a
954 /// signal that kills the process outright — and that loses `?1049l` and 1106 /// signal that kills the process outright — and that loses `?1049l` and
955 /// `?25h` with it, leaving the user on an alternate screen with no cursor, 1107 /// `?25h` with it, leaving the user on an alternate screen with no cursor,
956 /// so a title stack one deeper is not the part they will notice. Same 1108 /// so a title stack one deeper is not the part they will notice. Same
957 /// exposure as every other line of the teardown, not a new one. 1109 /// exposure as every other line of the teardown, not a new one.
958 pub fn ownTerminal(self: *Core) !void { 1110 pub fn ownTerminal(self: *Core) !void {
959 if (self.is_tty and !self.alt_screen) { 1111 if (self.is_tty and self.claim == .none) {
960 try proto.writeAllFd(self.out_fd, terminal_setup); 1112 try proto.writeAllFd(self.out_fd, terminal_setup);
961 self.alt_screen = true; 1113 self.claim = .whole;
1114 }
1115 }
1116
1117 /// Take the terminal for this session ALONE, on a screen somebody else
1118 /// already owns — the wall's promote.
1119 ///
1120 /// Where `ownTerminal` writes the whole `terminal_setup` once per
1121 /// process, this writes `session_claim` once per PROMOTE and the wall
1122 /// wrote the screen half (`wall_setup`) for its own lifetime. What a
1123 /// session needs is exactly the mouse modes: without them a host
1124 /// terminal answers the wheel by synthesising arrow keys (DEC 1007)
1125 /// that land in the session as input, which is what a zoomed tile did
1126 /// before this existed — the whole point of routing a tile's input
1127 /// through a Core.
1128 ///
1129 /// It sets `claim`, and `claim` is what arms the undo. Every mode a
1130 /// promoted session then asks this terminal for goes out under that
1131 /// flag (`writeSideChannel`) and comes off under it
1132 /// (`releaseTerminal`), so the set and its undo are one flag rather
1133 /// than an ordering — `ownTerminal`'s argument, applied to the pair
1134 /// that turns over many times per wall instead of once per process.
1135 pub fn claimTerminal(self: *Core) void {
1136 if (!self.is_tty or self.claim != .none) return;
1137 proto.writeAllFd(self.out_fd, session_claim) catch {};
1138 self.claim = .session;
1139 }
1140
1141 /// Give the terminal back: the demote, and every other way out.
1142 ///
1143 /// Nothing goes on the WIRE — a demote is client-local, which is the
1144 /// whole of "an unzoomed tile claims nothing" — but plenty comes off
1145 /// the terminal, because the session that was promoted set modes on it.
1146 /// A wall left still reporting clicks into the user's shell is the
1147 /// failure this pairs against.
1148 ///
1149 /// The scroll view goes with it. The stripe that resumes paints live
1150 /// state from the same replica, so a Core still suppressing paints for
1151 /// a history page would come back to a zoom showing nothing.
1152 ///
1153 /// Idempotent, and it says which teardown by what was claimed — the
1154 /// same flag, read the other way.
1155 pub fn releaseTerminal(self: *Core) void {
1156 const held = self.claim;
1157 self.claim = .none;
1158 self.dropScrollView();
1159 switch (held) {
1160 .none => {},
1161 .whole => proto.writeAllFd(self.out_fd, terminal_teardown) catch {},
1162 .session => proto.writeAllFd(self.out_fd, session_release) catch {},
962 } 1163 }
963 } 1164 }
964 1165
1166 /// The replica's engine, for a driver that paints its OWN view of this
1167 /// session instead of the Core's.
1168 ///
1169 /// The wall's DEMOTED tile is the only such driver, and the access is
1170 /// deliberately this narrow: a stripe is a crop of the same grid,
1171 /// painted by the wall at the wall's rows, while the Core paints
1172 /// nothing at all because it holds no terminal claim. One replica per
1173 /// tile, one applier for it (replica.zig), two ways of looking at it.
1174 pub fn grid(self: *Core) *Engine {
1175 return self.rep.eng;
1176 }
1177
1178 /// May this Core paint now, and hold that answer until `endPaint`?
1179 /// Every write of GRID bytes goes through this pair; see `Sink`.
1180 fn beginPaint(self: *Core) bool {
1181 const b = self.sink.begin orelse return true;
1182 return b(self.sink.ctx);
1183 }
1184
1185 fn endPaint(self: *Core) void {
1186 if (self.sink.end) |e| e(self.sink.ctx);
1187 }
1188
1189 /// The whole screen from the replica. What a driver calls when the
1190 /// SCREEN went stale with the session saying nothing — the wall's
1191 /// promote, where the replica has been hot the entire time the tile was
1192 /// a stripe and the zoom is therefore a local repaint at zero round
1193 /// trips.
1194 ///
1195 /// The overlay goes back on top, because the rows just drawn have
1196 /// overwritten predictions that are still outstanding.
1197 pub fn repaint(self: *Core) !void {
1198 if (!self.beginPaint()) return;
1199 defer self.endPaint();
1200 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd);
1201 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.out_fd);
1202 }
1203
1204 /// The whole screen from the replica, with nothing put back on top —
1205 /// every rollback path, where the overlay has just been abandoned.
1206 fn paintFull(self: *Core) !void {
1207 if (!self.beginPaint()) return;
1208 defer self.endPaint();
1209 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd);
1210 }
1211
965 /// A one-line marker in the corner, painted over by the next full 1212 /// A one-line marker in the corner, painted over by the next full
966 /// repaint — the right lifetime for something the user needs to read. 1213 /// repaint — the right lifetime for something the user needs to read.
967 /// ASCII only: `bannerText` places the label by byte length. 1214 /// ASCII only: `bannerText` places the label by byte length.
968 pub fn banner(self: *Core, text: []const u8) void { 1215 pub fn banner(self: *Core, text: []const u8) void {
969 if (self.is_tty) paint_mod.paintBanner(self.out_fd, self.size, text); 1216 if (!self.is_tty or !self.beginPaint()) return;
1217 defer self.endPaint();
1218 paint_mod.paintBanner(self.out_fd, self.size, text);
970 } 1219 }
971 1220
972 /// Answer a SIGWINCH, if one arrived. Only the local clip size changes 1221 /// Answer a SIGWINCH, if one arrived. Only the local clip size changes
@@ -995,7 +1244,7 @@ pub const Core = struct {
995 /// screen for the rest of the session. 1244 /// screen for the rest of the session.
996 pub fn idle(self: *Core) !void { 1245 pub fn idle(self: *Core) !void {
997 if (self.overlay.expire(std.time.milliTimestamp()) == .contradicted and self.scroll_rows == 0) { 1246 if (self.overlay.expire(std.time.milliTimestamp()) == .contradicted and self.scroll_rows == 0) {
998 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); 1247 try self.paintFull();
999 } 1248 }
1000 } 1249 }
1001 1250
@@ -1006,13 +1255,13 @@ pub const Core = struct {
1006 /// screen under anything outstanding. None of that says a prediction 1255 /// screen under anything outstanding. None of that says a prediction
1007 /// was wrong — it says we can no longer find out, so the queue goes and 1256 /// was wrong — it says we can no longer find out, so the queue goes and
1008 /// the counters do not move. 1257 /// the counters do not move.
1009 pub fn snapshotTaken(self: *Core) !void { 1258 fn snapshotTaken(self: *Core) !void {
1010 self.overlay.setGrid(self.rep.grid.cols, self.rep.grid.rows); 1259 self.overlay.setGrid(self.rep.grid.cols, self.rep.grid.rows);
1011 self.overlay.setResizePending(false); 1260 self.overlay.setResizePending(false);
1012 self.overlay.flush(); 1261 self.overlay.flush();
1013 self.overlay.noteSeq(self.rep.last_seq); 1262 self.overlay.noteSeq(self.rep.last_seq);
1014 if (self.scroll_rows == 0) { 1263 if (self.scroll_rows == 0) {
1015 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); 1264 try self.paintFull();
1016 self.repaint_after_resync = false; // banner painted over 1265 self.repaint_after_resync = false; // banner painted over
1017 } 1266 }
1018 } 1267 }
@@ -1020,7 +1269,7 @@ pub const Core = struct {
1020 /// The replica has taken a delta: judge the overlay against it and 1269 /// The replica has taken a delta: judge the overlay against it and
1021 /// paint. `payload` is the same frame the replica was fed — the row 1270 /// paint. `payload` is the same frame the replica was fed — the row
1022 /// deltas are what `paintDeltaClipped` draws. 1271 /// deltas are what `paintDeltaClipped` draws.
1023 pub fn deltaTaken(self: *Core, payload: []const u8) !void { 1272 fn deltaTaken(self: *Core, payload: []const u8) !void {
1024 // Judged against the replica the frame has just been fed into, 1273 // Judged against the replica the frame has just been fed into,
1025 // which is the only authority there is. 1274 // which is the only authority there is.
1026 const verdict = reconcileOverlay( 1275 const verdict = reconcileOverlay(
@@ -1032,7 +1281,8 @@ pub const Core = struct {
1032 ); 1281 );
1033 // While scrolled the replica still tracks live output; the repaint 1282 // While scrolled the replica still tracks live output; the repaint
1034 // on scroll exit comes from it. 1283 // on scroll exit comes from it.
1035 if (self.scroll_rows == 0) { 1284 if (self.scroll_rows == 0 and self.beginPaint()) {
1285 defer self.endPaint();
1036 if (self.repaint_after_resync or verdict == .contradicted) { 1286 if (self.repaint_after_resync or verdict == .contradicted) {
1037 // First frame back after a reconnect. The daemon sent only 1287 // First frame back after a reconnect. The daemon sent only
1038 // what changed, which is correct — but the screen still 1288 // what changed, which is correct — but the screen still
@@ -1045,6 +1295,11 @@ pub const Core = struct {
1045 // is certainly right. It is affordable precisely because 1295 // is certainly right. It is affordable precisely because
1046 // reconcile v2 made contradictions rare — a burst outrunning 1296 // reconcile v2 made contradictions rare — a burst outrunning
1047 // the round trip is no longer one. 1297 // the round trip is no longer one.
1298 //
1299 // Painted raw rather than through `paintFull`: this arm and
1300 // the overlay below it are ONE hold of the sink (a zoom that
1301 // moved between them would put the two halves of this frame
1302 // on two different screens), and the sink is not reentrant.
1048 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); 1303 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd);
1049 self.repaint_after_resync = false; 1304 self.repaint_after_resync = false;
1050 } else { 1305 } else {
@@ -1062,12 +1317,12 @@ pub const Core = struct {
1062 /// Mode churn is ordinary — readline does it around every command — so 1317 /// Mode churn is ordinary — readline does it around every command — so
1063 /// the repaint is spent only when the flush actually took something off 1318 /// the repaint is spent only when the flush actually took something off
1064 /// the screen. 1319 /// the screen.
1065 pub fn ptyModeChanged(self: *Core, payload: []const u8) !Pass { 1320 fn ptyModeChanged(self: *Core, payload: []const u8) !Pass {
1066 const flags = proto.decodePtyMode(payload) catch return .skip; 1321 const flags = proto.decodePtyMode(payload) catch return .skip;
1067 const had_pending = self.overlay.pendingCount() > 0; 1322 const had_pending = self.overlay.pendingCount() > 0;
1068 self.overlay.setMode(flags); 1323 self.overlay.setMode(flags);
1069 if (had_pending and self.overlay.pendingCount() == 0 and self.scroll_rows == 0) { 1324 if (had_pending and self.overlay.pendingCount() == 0 and self.scroll_rows == 0) {
1070 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); 1325 try self.paintFull();
1071 } 1326 }
1072 return .carry_on; 1327 return .carry_on;
1073 } 1328 }
@@ -1075,15 +1330,17 @@ pub const Core = struct {
1075 /// A page of history, answering the request `forward` sent when the 1330 /// A page of history, answering the request `forward` sent when the
1076 /// view moved. Ignored once the view is live again: the page would be 1331 /// view moved. Ignored once the view is live again: the page would be
1077 /// painted over a screen it no longer describes. 1332 /// painted over a screen it no longer describes.
1078 pub fn scrollbackPage(self: *Core, payload: []const u8) !Pass { 1333 fn scrollbackPage(self: *Core, payload: []const u8) !Pass {
1079 if (self.scroll_rows == 0 or payload.len < 6) return .skip; 1334 if (self.scroll_rows == 0 or payload.len < 6) return .skip;
1335 if (!self.beginPaint()) return .carry_on;
1336 defer self.endPaint();
1080 try paint_mod.renderScrollback(self.alloc, payload[6..], self.size, self.out_fd); 1337 try paint_mod.renderScrollback(self.alloc, payload[6..], self.size, self.out_fd);
1081 return .carry_on; 1338 return .carry_on;
1082 } 1339 }
1083 1340
1084 /// A term_event or term_modes frame: decoded by the shared core, then 1341 /// A term_event or term_modes frame: decoded by the shared core, then
1085 /// rendered onto the host terminal by this platform's adapters. 1342 /// rendered onto the host terminal by this platform's adapters.
1086 pub fn semanticFrame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !void { 1343 fn semanticFrame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !void {
1087 switch (self.semantic.receive(frame_type, payload)) { 1344 switch (self.semantic.receive(frame_type, payload)) {
1088 .ignored => {}, 1345 .ignored => {},
1089 .state => |state| { 1346 .state => |state| {
@@ -1096,7 +1353,7 @@ pub const Core = struct {
1096 try writeSideChannel( 1353 try writeSideChannel(
1097 self.alloc, 1354 self.alloc,
1098 self.out_fd, 1355 self.out_fd,
1099 self.alt_screen, 1356 self.claim != .none,
1100 client_core.State, 1357 client_core.State,
1101 state, 1358 state,
1102 appendTermState, 1359 appendTermState,
@@ -1105,7 +1362,7 @@ pub const Core = struct {
1105 .effect => |effect| try writeSideChannel( 1362 .effect => |effect| try writeSideChannel(
1106 self.alloc, 1363 self.alloc,
1107 self.out_fd, 1364 self.out_fd,
1108 self.alt_screen, 1365 self.claim != .none,
1109 client_core.Effect, 1366 client_core.Effect,
1110 effect, 1367 effect,
1111 appendHostEffect, 1368 appendHostEffect,
@@ -1122,17 +1379,102 @@ pub const Core = struct {
1122 /// no-op with no counter or stack behind it. Note the daemon never 1379 /// no-op with no counter or stack behind it. Note the daemon never
1123 /// sends an empty one, so a repeat can never clear a title the user is 1380 /// sends an empty one, so a repeat can never clear a title the user is
1124 /// looking at. 1381 /// looking at.
1125 pub fn titleFrame(self: *Core, payload: []const u8) !void { 1382 fn titleFrame(self: *Core, payload: []const u8) !void {
1126 try writeSideChannel( 1383 try writeSideChannel(
1127 self.alloc, 1384 self.alloc,
1128 self.out_fd, 1385 self.out_fd,
1129 self.alt_screen, 1386 self.claim != .none,
1130 []const u8, 1387 []const u8,
1131 payload, 1388 payload,
1132 appendTermTitle, 1389 appendTermTitle,
1133 ); 1390 );
1134 } 1391 }
1135 1392
1393 /// One frame from the daemon, routed. The exhaustive switch over the
1394 /// wire lives HERE and nowhere else.
1395 ///
1396 /// It applies to the replica too, which is the part worth defending: a
1397 /// driver that applied on its own would need to know which types the
1398 /// replica takes, which is a second switch over the same enum — and the
1399 /// two that existed before this had already drifted, one treating a
1400 /// short snapshot as a `continue` and the other as a broken frame loop.
1401 /// The Core owns the replica (`rep`), so the Core feeds it.
1402 ///
1403 /// What comes back is only what is LEFT (see `Routed`): the driver's
1404 /// own bookkeeping on first state, its re-attach spelling on a resync,
1405 /// and the three frames that are nobody's but the driver's.
1406 ///
1407 /// Errors are the replica's, unchanged: a snapshot too short to read is
1408 /// `.skip` because `readSnapshotPrefix` left everything untouched, and
1409 /// anything else out of a resize stays loud.
1410 pub fn frame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !Routed {
1411 switch (frame_type) {
1412 .snapshot => {
1413 _ = self.rep.apply(.snapshot, payload) catch |err| switch (err) {
1414 error.BadPayload => return .skip,
1415 else => |e| return e,
1416 };
1417 try self.snapshotTaken();
1418 return .state;
1419 },
1420 .delta => {
1421 // `apply` marks state BEFORE it can refuse, so a delta the
1422 // replica could not compose still proves the attach landed
1423 // — which is why `.resync` counts as state to its driver.
1424 const applied = try self.rep.apply(.delta, payload);
1425 if (applied == .resync) return .resync;
1426 try self.deltaTaken(payload);
1427 return .state;
1428 },
1429 .pty_mode => return switch (try self.ptyModeChanged(payload)) {
1430 .skip => .skip,
1431 .carry_on => .handled,
1432 },
1433 .scrollback_chunk => return switch (try self.scrollbackPage(payload)) {
1434 .skip => .skip,
1435 .carry_on => .handled,
1436 },
1437 .term_event, .term_modes => {
1438 try self.semanticFrame(frame_type, payload);
1439 return .handled;
1440 },
1441 .term_title => {
1442 try self.titleFrame(payload);
1443 return .handled;
1444 },
1445 .exit_status, .taken_over, .sessions_reply => return .not_mine,
1446 // Named rather than swept into the `else`, so that adding a
1447 // meaning for one of them is an edit here and not a new switch
1448 // somewhere else. Every one is either a reply to a request only
1449 // muxa makes, or a frame that travels the other way.
1450 .stats_reply,
1451 .endpoint_reply,
1452 .cmd_state,
1453 .await_reply,
1454 .status_reply,
1455 .selection_reply,
1456 .dump_reply,
1457 .attach,
1458 .input,
1459 .resize,
1460 .detach,
1461 .fetch_scrollback,
1462 .stats_req,
1463 .stop_req,
1464 .endpoint_req,
1465 .await_req,
1466 .status_req,
1467 .selection_req,
1468 .sessions_req,
1469 .debug_dump,
1470 => return .skip,
1471 // MsgType is open (`_`): a daemon newer than this client can
1472 // send a type this build has never heard of. Ignoring it is the
1473 // forward-compatible answer and always has been.
1474 _ => return .skip,
1475 }
1476 }
1477
1136 /// Read one chunk of what the user typed and split the chord off the 1478 /// Read one chunk of what the user typed and split the chord off the
1137 /// front of it. Null is EOF: there is nothing more coming from this 1479 /// front of it. Null is EOF: there is nothing more coming from this
1138 /// descriptor and the driver stops polling it. 1480 /// descriptor and the driver stops polling it.
@@ -1160,7 +1502,7 @@ pub const Core = struct {
1160 // session asked the terminal for — applied to the one device the 1502 // session asked the terminal for — applied to the one device the
1161 // client also has a use for. 1503 // client also has a use for.
1162 // 1504 //
1163 // `alt_screen` gates it for a second reason, and it is the one that 1505 // The claim gates it for a second reason, and it is the one that
1164 // bites hardest: it is the flag that says this Core took a terminal 1506 // bites hardest: it is the flag that says this Core took a terminal
1165 // over and wrote `client_mouse_setup` to it. A client whose stdin 1507 // over and wrote `client_mouse_setup` to it. A client whose stdin
1166 // is a PIPE never asked anyone for mouse reports, so nothing it 1508 // is a PIPE never asked anyone for mouse reports, so nothing it
@@ -1171,7 +1513,7 @@ pub const Core = struct {
1171 // with the escape deleted. 1513 // with the escape deleted.
1172 var keys = typed; 1514 var keys = typed;
1173 var wheel: i32 = 0; 1515 var wheel: i32 = 0;
1174 if (!self.alt_screen or self.semantic.terminal_modes.appMouse()) { 1516 if (self.claim == .none or self.semantic.terminal_modes.appMouse()) {
1175 self.mouse.reset(); 1517 self.mouse.reset();
1176 } else { 1518 } else {
1177 const m = self.mouse.feed(typed, &self.mouse_buf); 1519 const m = self.mouse.feed(typed, &self.mouse_buf);
@@ -1227,7 +1569,7 @@ pub const Core = struct {
1227 if (next == 0 and (self.scroll_rows > 0 or key_dn)) { 1569 if (next == 0 and (self.scroll_rows > 0 or key_dn)) {
1228 self.scroll_rows = 0; 1570 self.scroll_rows = 0;
1229 self.overlay.setScrollMode(false); 1571 self.overlay.setScrollMode(false);
1230 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); 1572 try self.paintFull();
1231 } else if (next != self.scroll_rows) { 1573 } else if (next != self.scroll_rows) {
1232 self.scroll_rows = next; 1574 self.scroll_rows = next;
1233 // The cursor is no longer where the user is looking, so a 1575 // The cursor is no longer where the user is looking, so a
@@ -1252,12 +1594,19 @@ pub const Core = struct {
1252 // the keystroke goes where it was aimed. 1594 // the keystroke goes where it was aimed.
1253 self.scroll_rows = 0; 1595 self.scroll_rows = 0;
1254 self.overlay.setScrollMode(false); 1596 self.overlay.setScrollMode(false);
1255 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); 1597 try self.paintFull();
1256 } else { 1598 } else {
1257 // Speculate before sending, so the glyph is on screen while the 1599 // Speculate before sending, so the glyph is on screen while the
1258 // keystroke is still in flight. The bytes that go out are 1600 // keystroke is still in flight. The bytes that go out are
1259 // unchanged either way. 1601 // unchanged either way — and they go out whatever the sink
1260 offerKeystroke(self.alloc, &self.overlay, self.rep.eng, keys, self.size, self.out_fd); 1602 // says: a demoted wall tile still owes its session the bytes
1603 // that were typed at it while it was the zoom, it just may not
1604 // draw them. An overlay glyph on a stripe would be graffiti on
1605 // whichever session the zoom moved to.
1606 if (self.beginPaint()) {
1607 defer self.endPaint();
1608 offerKeystroke(self.alloc, &self.overlay, self.rep.eng, keys, self.size, self.out_fd);
1609 }
1261 transport.writeFrame(.input, keys) catch return .lost; 1610 transport.writeFrame(.input, keys) catch return .lost;
1262 // These keystrokes are lost with the transport, by the same 1611 // These keystrokes are lost with the transport, by the same
1263 // policy that drops what is typed while disconnected. 1612 // policy that drops what is typed while disconnected.