43667a46
refactor: one exhaustive frame switch, in the Core
a73x 2026-08-20 04:16
Commit message
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. |