9c9ccd0f
refactor: one session's interaction is one interact.Core
a73x 2026-08-20 01:45
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -21,7 +21,8 @@ Files are large and comment-dense (~44% of Zig bytes are `//`). Reading the repo | |||
| 21 | costs ~800k tokens; every token stays in context and is re-billed each turn. | 21 | costs ~800k tokens; every token stays in context and is re-billed each turn. |
| 22 | 22 | ||
| 23 | - **Never `cat` these:** `src/server.zig` (9.1k lines, ~110k tok), | 23 | - **Never `cat` these:** `src/server.zig` (9.1k lines, ~110k tok), |
| 24 | `test/e2e.sh` (4.2k), `docs/decisions.md` (3.7k), `src/client.zig` (3.1k). | 24 | `test/e2e.sh` (4.2k), `docs/decisions.md` (3.7k), `src/client.zig` (2.8k), |
| 25 | `src/interact.zig` (2.2k). | ||
| 25 | Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window. | 26 | Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window. |
| 26 | - Every module has a `//!` header stating its contract. `head -12 src/X.zig` | 27 | - Every module has a `//!` header stating its contract. `head -12 src/X.zig` |
| 27 | answers most "what is this" questions for ~200 tokens. | 28 | answers most "what is this" questions for ~200 tokens. |
| @@ -37,8 +38,10 @@ Layers are enforced in `build.zig`'s module table (grep `.layer =` for the graph | |||
| 37 | |---|---| | 38 | |---|---| |
| 38 | | 0 | `protocol` `engine` `pty` `quic` `keymap` `xdg` `sockpath` `proxy` `testtmp` | | 39 | | 0 | `protocol` `engine` `pty` `quic` `keymap` `xdg` `sockpath` `proxy` `testtmp` | |
| 39 | | 1 | `quic_server` `quic_client` `predict` `spawn` `handoff` `delta` `cmd` `shellint` `replica` `paint` | | 40 | | 1 | `quic_server` `quic_client` `predict` `spawn` `handoff` `delta` `cmd` `shellint` `replica` `paint` | |
| 40 | | 2 | `server` `client` `muxa` | | 41 | | 2 | `server` `muxa` `interact` | |
| 41 | | 3/4 | `mux_main` `main`(muxd) `webhub` `wallview` `webhub_main`(muxweb) | | 42 | | 3 | `client` `main`(muxd) | |
| 43 | | 4 | `webhub` `wallview` | | ||
| 44 | | 5 | `mux_main` `webhub_main`(muxweb) | | ||
| 42 | 45 | ||
| 43 | Binaries: `muxd` (daemon), `mux` (client), `muxa` (agent client, JSON verbs), | 46 | Binaries: `muxd` (daemon), `mux` (client), `muxa` (agent client, JSON verbs), |
| 44 | `muxweb` (browser hub). Test fixtures in `test/`: `ptyclient` (real client on a | 47 | `muxweb` (browser hub). Test fixtures in `test/`: `ptyclient` (real client on a |
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -87,9 +87,11 @@ fn fatal(comptime fmt: []const u8, args: anytype) noreturn { | |||
| 87 | /// are impossible, not detected. (Dependency edges — ghostty — and | 87 | /// are impossible, not detected. (Dependency edges — ghostty — and |
| 88 | /// build_options are explicitly outside the table's jurisdiction and | 88 | /// build_options are explicitly outside the table's jurisdiction and |
| 89 | /// stay wired by hand.) Layers are the topological strata of the production | 89 | /// stay wired by hand.) Layers are the topological strata of the production |
| 90 | /// graph, computed 2026-08-14 and FROZEN: a new import that would | 90 | /// graph, computed 2026-08-14, re-stratified 2026-08-20 (the interact |
| 91 | /// flatten or invert a stratum fails at comptime, and re-stratifying | 91 | /// extraction pushed client to 3 and everything above it up one), and |
| 92 | /// requires editing this table, which is the point. | 92 | /// FROZEN: a new import that would flatten or invert a stratum fails at |
| 93 | /// comptime, and re-stratifying requires editing this table, which is | ||
| 94 | /// the point. | ||
| 93 | const ModSpec = struct { | 95 | const ModSpec = struct { |
| 94 | name: []const u8, | 96 | name: []const u8, |
| 95 | path: []const u8, | 97 | path: []const u8, |
| @@ -249,7 +251,15 @@ const mod_table = [_]ModSpec{ | |||
| 249 | // 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 |
| 250 | // 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 |
| 251 | // theirs too, so the writer cannot live up in mux_main. | 253 | // theirs too, so the writer cannot live up in mux_main. |
| 252 | .{ .name = "client", .path = "src/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "engine", "protocol", "replica", "client_core", "interact", "quic_client", "quic", "predict", "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", "paint", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 255 | // The daemon entrypoint loads the key and constructs the listener, so | ||
| 256 | // it needs quic/quic_server directly rather than through the server. | ||
| 257 | // `muxd endpoint` prints the announce line handoff spells; sockpath is | ||
| 258 | // the sun_path bound, checked before any verb acts on the path; and the | ||
| 259 | // keygen round-trip test needs a directory to generate into, which the | ||
| 260 | // daemon itself never touches. | ||
| 261 | .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | ||
| 262 | // ---- layer 4 ---- | ||
| 253 | // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route | 263 | // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route |
| 254 | // table, WS endpoint naming. Assets are injected (the exe root | 264 | // table, WS endpoint naming. Assets are injected (the exe root |
| 255 | // @embedFiles them), so its tests build no artifacts. | 265 | // @embedFiles them), so its tests build no artifacts. |
| @@ -266,13 +276,6 @@ const mod_table = [_]ModSpec{ | |||
| 266 | // and phase 3 promotes the tile into that core rather than growing a | 276 | // and phase 3 promotes the tile into that core rather than growing a |
| 267 | // second copy of it. | 277 | // second copy of it. |
| 268 | .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "replica", "paint", "predict" }, .quic_tests = true }, | 278 | .{ .name = "wallview", .path = "src/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "handoff", "xdg", "sockpath", "proxy", "engine", "replica", "paint", "predict" }, .quic_tests = true }, |
| 269 | // The daemon entrypoint loads the key and constructs the listener, so | ||
| 270 | // it needs quic/quic_server directly rather than through the server. | ||
| 271 | // `muxd endpoint` prints the announce line handoff spells; sockpath is | ||
| 272 | // the sun_path bound, checked before any verb acts on the path; and the | ||
| 273 | // keygen round-trip test needs a directory to generate into, which the | ||
| 274 | // daemon itself never touches. | ||
| 275 | .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | ||
| 276 | // ---- layer 5 ---- | 279 | // ---- layer 5 ---- |
| 277 | // wall owns the spelling grammar and the state file, so argv is parsed | 280 | // wall owns the spelling grammar and the state file, so argv is parsed |
| 278 | // by the SAME rules the page's POST /tiles and the restored file are — | 281 | // by the SAME rules the page's POST /tiles and the restored file are — |
| @@ -283,8 +286,8 @@ const mod_table = [_]ModSpec{ | |||
| 283 | // sockpath is the sun_path bound only; the client binds no socket itself. | 286 | // sockpath is the sun_path bound only; the client binds no socket itself. |
| 284 | // protocol is the session-name validator alone (validSessionName): a bad | 287 | // protocol is the session-name validator alone (validSessionName): a bad |
| 285 | // --session has to be a usage error here, at parse, not bytes some | 288 | // --session has to be a usage error here, at parse, not bytes some |
| 286 | // daemon downstream has to notice and refuse. Layer 4 since `mux wall` | 289 | // daemon downstream has to notice and refuse. Layer 5 since `mux wall` |
| 287 | // pulled in wallview (layer 3); wall rides along for the no-arg wall | 290 | // pulled in wallview (layer 4); wall rides along for the no-arg wall |
| 288 | // (the state file the browser hub builds). | 291 | // (the state file the browser hub builds). |
| 289 | .{ .name = "mux", .path = "src/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall" }, .quic_tests = true }, | 292 | .{ .name = "mux", .path = "src/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall" }, .quic_tests = true }, |
| 290 | }; | 293 | }; |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -3240,7 +3240,10 @@ two dialects. | |||
| 3240 | COMPUTED topological strata, frozen 2026-08-14 — hand-assignment was | 3240 | COMPUTED topological strata, frozen 2026-08-14 — hand-assignment was |
| 3241 | tried first and misplaced engine on the first draft. Changing the | 3241 | tried first and misplaced engine on the first draft. Changing the |
| 3242 | architecture now means editing the table, which is the point. Proven by | 3242 | architecture now means editing the table, which is the point. Proven by |
| 3243 | extract-and-diff: 76 edges before and after, empty diff. | 3243 | extract-and-diff: 76 edges before and after, empty diff. (Re-stratified |
| 3244 | 2026-08-20: the interact extraction added a stratum under client, so | ||
| 3245 | client sits at 3 and the entrypoints at 5 now; the freeze and the | ||
| 3246 | comptime law are unchanged.) | ||
| 3244 | - **Two adjudicated edges.** `server -> replica` is test-only (sole use is | 3247 | - **Two adjudicated edges.** `server -> replica` is test-only (sole use is |
| 3245 | the applyFrame test helper) and lives in the test_imports column. | 3248 | the applyFrame test helper) and lives in the test_imports column. |
| 3246 | `client -> proxy` is production (ignoreSigpipe in the live attach path) | 3249 | `client -> proxy` is production (ignoreSigpipe in the live attach path) |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,23 +1,22 @@ | |||
| 1 | //! mux client: connects, attaches, maintains a replica engine rebuilt | 1 | //! mux client: connects and attaches, then drives one `interact.Core` |
| 2 | //! from snapshots and advanced by row deltas, repaints the local | 2 | //! over the link until the session ends. What this file owns is the LINK |
| 3 | //! terminal, forwards keystrokes. The replica is sized to the daemon's | 3 | //! and the session's lifetime — targets and dialling, the ssh handoff, |
| 4 | //! authoritative grid (snapshot prefixes carry it), which under the | 4 | //! the attach handshake, reconnect, the wall-file record of an attach, |
| 5 | //! latest-wins resize policy may differ from this tty; paints are | 5 | //! and what each chord means. What happens at the terminal once a link is |
| 6 | //! clipped to the tty. | 6 | //! up — the replica rebuilt from snapshots and advanced by row deltas, |
| 7 | //! the paints, prediction, the chord and mouse filters — is interact.zig's | ||
| 8 | //! (the replica is sized to the daemon's authoritative grid, which under | ||
| 9 | //! latest-wins may differ from this tty; paints are clipped to the tty). | ||
| 10 | //! | ||
| 7 | //! Keybinding layer: Ctrl-\ (0x1c) is a command prefix in a live session — | 11 | //! Keybinding layer: Ctrl-\ (0x1c) is a command prefix in a live session — |
| 8 | //! `Ctrl-\ d` or `Ctrl-\ Ctrl-\` detaches, `Ctrl-\ c` creates a new session | 12 | //! `Ctrl-\ d` or `Ctrl-\ Ctrl-\` detaches, `Ctrl-\ c` creates a new session |
| 9 | //! and switches to it in place, `Ctrl-\ n` / `Ctrl-\ p` step to the next or | 13 | //! and switches to it in place, `Ctrl-\ n` / `Ctrl-\ p` step to the next or |
| 10 | //! previous session in the daemon's list, `Ctrl-\ w` shows them all as a | 14 | //! previous session in the daemon's list, `Ctrl-\ w` shows them all as a |
| 11 | //! read-only wall (a `mux wall` child on this terminal) and returns here | 15 | //! read-only wall (a `mux wall` child on this terminal) and returns here |
| 12 | //! when it leaves. While dialling or reconnecting there is no session to | 16 | //! when it leaves. While dialling or reconnecting there is no session to |
| 13 | //! command and a bare Ctrl-\ still aborts. The chord table itself, and | 17 | //! command and a bare Ctrl-\ still aborts. |
| 14 | //! every other thing that happens between a user and an already-open | ||
| 15 | //! session, live in interact.zig; what a chord MEANS is still decided | ||
| 16 | //! here. | ||
| 17 | const std = @import("std"); | 18 | const std = @import("std"); |
| 18 | const Engine = @import("engine").Engine; | ||
| 19 | const Replica = @import("replica").Replica; | 19 | const Replica = @import("replica").Replica; |
| 20 | const client_core = @import("client_core"); | ||
| 21 | // The session-interaction core: chords, the wheel, prediction, the side | 20 | // The session-interaction core: chords, the wheel, prediction, the side |
| 22 | // channels, terminal ownership. Everything this file keeps is about | 21 | // channels, terminal ownership. Everything this file keeps is about |
| 23 | // BUILDING a link and deciding what a chord means. | 22 | // BUILDING a link and deciding what a chord means. |
| @@ -26,13 +25,13 @@ const proto = @import("protocol"); | |||
| 26 | const TmpDir = @import("testtmp").TmpDir; | 25 | const TmpDir = @import("testtmp").TmpDir; |
| 27 | const quic_client = @import("quic_client"); | 26 | const quic_client = @import("quic_client"); |
| 28 | const quic = @import("quic"); | 27 | const quic = @import("quic"); |
| 29 | const predict = @import("predict"); | ||
| 30 | const handoff = @import("handoff"); | 28 | const handoff = @import("handoff"); |
| 31 | // 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 |
| 32 | // user's tile is this module and not mux_main. | 30 | // user's tile is this module and not mux_main. |
| 33 | const wall = @import("wall"); | 31 | const wall = @import("wall"); |
| 34 | // Named `paint_mod` because paintOverlay holds a local ArrayList called | 32 | // The [reconnecting] banner, and nothing else: painting the replica is |
| 35 | // `paint`, which a container-level `paint` would collide with. | 33 | // interact's. Named `paint_mod` for the collision the name `paint` used |
| 34 | // to have with a local here. | ||
| 36 | const paint_mod = @import("paint"); | 35 | const paint_mod = @import("paint"); |
| 37 | // For ignoreSigpipe only, which proxy.zig owns. | 36 | // For ignoreSigpipe only, which proxy.zig owns. |
| 38 | const proxy = @import("proxy"); | 37 | const proxy = @import("proxy"); |
| @@ -1454,69 +1453,29 @@ fn session( | |||
| 1454 | // would inherit an ignored SIGPIPE they never asked for. | 1453 | // would inherit an ignored SIGPIPE they never asked for. |
| 1455 | proxy.ignoreSigpipe(); | 1454 | proxy.ignoreSigpipe(); |
| 1456 | 1455 | ||
| 1457 | const stdin_fd = std.posix.STDIN_FILENO; | 1456 | // Registered BEFORE the core, so it runs after `core.deinit()` puts the |
| 1458 | const stdout_fd = std.posix.STDOUT_FILENO; | 1457 | // terminal back: messages land on the normal screen, not the wiped |
| 1459 | const is_tty = std.posix.isatty(stdin_fd); | 1458 | // alternate one. The prediction stats line rides the same rule from |
| 1459 | // inside deinit. | ||
| 1460 | var exit_msg: ?[]const u8 = null; | ||
| 1461 | defer if (exit_msg) |m| std.debug.print("{s}\n", .{m}); | ||
| 1460 | 1462 | ||
| 1461 | var size = interact.ttySize(stdout_fd) orelse proto.Size{ .cols = 80, .rows = 24 }; | 1463 | // Everything this session does to a terminal, and everything it holds |
| 1464 | // about the session behind it: replica, overlay, chords, the wheel. | ||
| 1465 | // What stays here is the transport, the attach handshake, and what a | ||
| 1466 | // chord MEANS. | ||
| 1467 | var core = try interact.Core.init( | ||
| 1468 | alloc, | ||
| 1469 | std.posix.STDIN_FILENO, | ||
| 1470 | std.posix.STDOUT_FILENO, | ||
| 1471 | ); | ||
| 1472 | defer core.deinit(); | ||
| 1473 | try core.takeTerminal(); | ||
| 1462 | 1474 | ||
| 1463 | var eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); | ||
| 1464 | defer eng.deinit(); | ||
| 1465 | // The replay core, extracted to replica.zig; the engine stays owned | ||
| 1466 | // here (the Replica borrows it), so the deinit above is the one owner. | ||
| 1467 | var rep = Replica.init(alloc, eng); | ||
| 1468 | // Whether this run has already written its tile. Latched rather than | 1475 | // Whether this run has already written its tile. Latched rather than |
| 1469 | // re-derived because a reconnect clears `state_since_attach` and it | 1476 | // re-derived because a reconnect clears `state_since_attach` and it |
| 1470 | // turns true again — see `recordOnState`. | 1477 | // turns true again — see `recordOnState`. |
| 1471 | var tile_recorded = false; | 1478 | var tile_recorded = false; |
| 1472 | // Semantic terminal state and host effects are decoded once here, then | ||
| 1473 | // handed to the native adapter below. The web client owns another | ||
| 1474 | // instance of this same platform-neutral state machine. | ||
| 1475 | var semantic_core: client_core.ClientCore = .{}; | ||
| 1476 | |||
| 1477 | // Registered before the terminal-restore defer so it runs after it: | ||
| 1478 | // messages land on the normal screen, not the wiped alternate one. | ||
| 1479 | var exit_msg: ?[]const u8 = null; | ||
| 1480 | defer if (exit_msg) |m| std.debug.print("{s}\n", .{m}); | ||
| 1481 | |||
| 1482 | // Speculative echo. Born `.never` and stays there until a daemon tells | ||
| 1483 | // it otherwise, so an old daemon that has never heard of pty_mode gets a | ||
| 1484 | // client that predicts nothing at all. | ||
| 1485 | // | ||
| 1486 | // Registered here so the stats line lands after the terminal has been | ||
| 1487 | // put back and on the normal screen, like every other message. | ||
| 1488 | var overlay = predict.Overlay.init(alloc, size.cols, size.rows); | ||
| 1489 | defer overlay.deinit(); | ||
| 1490 | defer interact.dumpPredictStats(overlay.counters); | ||
| 1491 | |||
| 1492 | // Raw mode when we own a terminal. The alternate screen is NOT entered | ||
| 1493 | // here — see the first-frame gate in the loop below. | ||
| 1494 | var orig_termios: ?std.posix.termios = null; | ||
| 1495 | var alt_screen = false; | ||
| 1496 | if (is_tty) { | ||
| 1497 | const orig = try std.posix.tcgetattr(stdin_fd); | ||
| 1498 | orig_termios = orig; | ||
| 1499 | var raw = orig; | ||
| 1500 | raw.lflag.ICANON = false; | ||
| 1501 | raw.lflag.ECHO = false; | ||
| 1502 | raw.lflag.ISIG = false; | ||
| 1503 | raw.iflag.IXON = false; | ||
| 1504 | raw.iflag.ICRNL = false; | ||
| 1505 | try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); | ||
| 1506 | |||
| 1507 | var sa: std.posix.Sigaction = .{ | ||
| 1508 | .handler = .{ .handler = interact.onWinch }, | ||
| 1509 | .mask = std.posix.sigemptyset(), | ||
| 1510 | .flags = 0, | ||
| 1511 | }; | ||
| 1512 | std.posix.sigaction(std.posix.SIG.WINCH, &sa, null); | ||
| 1513 | } | ||
| 1514 | defer { | ||
| 1515 | // Only undo what was actually done: leaving the alternate screen we | ||
| 1516 | // never entered would wipe the user's own scrollback. | ||
| 1517 | if (alt_screen) proto.writeAllFd(stdout_fd, interact.terminal_teardown) catch {}; | ||
| 1518 | if (orig_termios) |t| std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {}; | ||
| 1519 | } | ||
| 1520 | 1479 | ||
| 1521 | // A fresh client process holds no state, so it asks for a full snapshot: | 1480 | // A fresh client process holds no state, so it asks for a full snapshot: |
| 1522 | // no seq, and no epoch to interpret one in. A transport that died between | 1481 | // no seq, and no epoch to interpret one in. A transport that died between |
| @@ -1527,7 +1486,7 @@ fn session( | |||
| 1527 | // "" writes exactly the old fixed 20 bytes), so a `mux` invoked without | 1486 | // "" writes exactly the old fixed 20 bytes), so a `mux` invoked without |
| 1528 | // `--session` still works unmodified against a pre-M18 daemon that has | 1487 | // `--session` still works unmodified against a pre-M18 daemon that has |
| 1529 | // never heard of a name tail. | 1488 | // never heard of a name tail. |
| 1530 | sendAttach(transport, size, 0, 0, session_name) catch { | 1489 | sendAttach(transport, core.size, 0, 0, session_name) catch { |
| 1531 | // Nothing has been read yet, so the epoch is 0 by construction. | 1490 | // Nothing has been read yet, so the epoch is 0 by construction. |
| 1532 | exit_msg = lostMsg(target, 0); | 1491 | exit_msg = lostMsg(target, 0); |
| 1533 | return .{ .exit = 1 }; | 1492 | return .{ .exit = 1 }; |
| @@ -1546,18 +1505,6 @@ fn session( | |||
| 1546 | } | 1505 | } |
| 1547 | 1506 | ||
| 1548 | var stdin_open = true; | 1507 | var stdin_open = true; |
| 1549 | // Scroll mode: 0 = live; N = viewing the screenful whose bottom sits N | ||
| 1550 | // rows above live. Rows rather than pages because the wheel moves by a | ||
| 1551 | // few lines and the keys move by a screen — one unit that expresses | ||
| 1552 | // both, and `fetch_scrollback` already addresses absolute rows. | ||
| 1553 | // View state, not replay state, so it stays here rather than in the | ||
| 1554 | // Replica (which holds grid/seq/epoch/history — see replica.zig). | ||
| 1555 | var scroll_rows: u32 = 0; | ||
| 1556 | // Mouse reports arrive in the same reads as keystrokes; this splits | ||
| 1557 | // them back out, across read boundaries. Sized to hold one chunk plus | ||
| 1558 | // whatever a previous read left mid-report — see MouseFilter.feed. | ||
| 1559 | var mouse: interact.MouseFilter = .{}; | ||
| 1560 | var mouse_buf: [interact.stdin_chunk + interact.MouseFilter.max_held]u8 = undefined; | ||
| 1561 | // Set by any site that finds the transport dead; serviced at the top of | 1508 | // Set by any site that finds the transport dead; serviced at the top of |
| 1562 | // the loop so the bookkeeping around a reconnect lives in one place. | 1509 | // the loop so the bookkeeping around a reconnect lives in one place. |
| 1563 | var needs_reconnect = false; | 1510 | var needs_reconnect = false; |
| @@ -1565,11 +1512,6 @@ fn session( | |||
| 1565 | // (the daemon may not have reaped our dead predecessor's slot yet). | 1512 | // (the daemon may not have reaped our dead predecessor's slot yet). |
| 1566 | // Set on the first reconnect, kept across its retries, cleared by state. | 1513 | // Set on the first reconnect, kept across its retries, cleared by state. |
| 1567 | var reconnect_grace_until: ?i64 = null; | 1514 | var reconnect_grace_until: ?i64 = null; |
| 1568 | // The first paint after a reconnect must be a full one, so the | ||
| 1569 | // [reconnecting] banner goes away with everything else now stale. | ||
| 1570 | var repaint_after_resync = false; | ||
| 1571 | // Chord state lives across reads, so it outlives one buffer. | ||
| 1572 | var prefix: interact.PrefixFilter = .{}; | ||
| 1573 | // Whether this run is still a switch's ARRIVAL, which is what makes a | 1515 | // Whether this run is still a switch's ARRIVAL, which is what makes a |
| 1574 | // pre-state refusal recoverable. Mutable because the property expires: | 1516 | // pre-state refusal recoverable. Mutable because the property expires: |
| 1575 | // see the reconnect below. | 1517 | // see the reconnect below. |
| @@ -1579,7 +1521,6 @@ fn session( | |||
| 1579 | // a reply nobody asked for cannot move a user off their session — and | 1521 | // a reply nobody asked for cannot move a user off their session — and |
| 1580 | // the intent is what says which name the answer names. | 1522 | // the intent is what says which name the answer names. |
| 1581 | var pending_switch: PendingSwitch = .{}; | 1523 | var pending_switch: PendingSwitch = .{}; |
| 1582 | var buf: [interact.stdin_chunk]u8 = undefined; | ||
| 1583 | while (true) { | 1524 | while (true) { |
| 1584 | if (needs_reconnect) { | 1525 | if (needs_reconnect) { |
| 1585 | needs_reconnect = false; | 1526 | needs_reconnect = false; |
| @@ -1594,37 +1535,23 @@ fn session( | |||
| 1594 | // `state_since_attach` cannot serve here: it is cleared on | 1535 | // `state_since_attach` cannot serve here: it is cleared on |
| 1595 | // every re-attach, so mid-session it would send us down this | 1536 | // every re-attach, so mid-session it would send us down this |
| 1596 | // exit path exactly when resuming is what we want. | 1537 | // exit path exactly when resuming is what we want. |
| 1597 | if (rep.session_epoch == 0) { | 1538 | if (core.rep.session_epoch == 0) { |
| 1598 | exit_msg = lostMsg(target, rep.session_epoch); | 1539 | exit_msg = lostMsg(target, core.rep.session_epoch); |
| 1599 | return .{ .exit = 1 }; | 1540 | return .{ .exit = 1 }; |
| 1600 | } | 1541 | } |
| 1601 | // A resync repaints live state, so a history page would be | 1542 | // Every route into here shares the reason (see dropScrollView), |
| 1602 | // silently replaced a moment later — and the banner would sit | 1543 | // so it is handled once. |
| 1603 | // over stale rows until the user happened to leave scroll mode. | 1544 | core.dropScrollView(); |
| 1604 | // Every route into here shares that, so it is handled once. | ||
| 1605 | scroll_rows = 0; | ||
| 1606 | // The overlay has to be told, and only the client can tell it: | ||
| 1607 | // `flush()` drops predictions but deliberately leaves the mode | ||
| 1608 | // bit alone, so a reconnect taken while scrolled would leave the | ||
| 1609 | // overlay suppressing with no page to suppress for. The | ||
| 1610 | // "any other key" exit at the bottom of the loop cannot rescue | ||
| 1611 | // it — that branch is guarded by `scroll_rows > 0`, which the | ||
| 1612 | // line above has just made false. Shift+PageDown still can | ||
| 1613 | // (its `scroll_rows == 0` arm clears the mode unconditionally), | ||
| 1614 | // so this is recoverable rather than terminal — but only by a | ||
| 1615 | // keystroke the user has no reason to guess, so prediction is | ||
| 1616 | // silently off until they do. | ||
| 1617 | overlay.setScrollMode(false); | ||
| 1618 | if (!reconnect( | 1545 | if (!reconnect( |
| 1619 | alloc, | 1546 | alloc, |
| 1620 | transport, | 1547 | transport, |
| 1621 | target, | 1548 | target, |
| 1622 | size, | 1549 | core.size, |
| 1623 | rep.last_seq, | 1550 | core.rep.last_seq, |
| 1624 | rep.session_epoch, | 1551 | core.rep.session_epoch, |
| 1625 | stdin_fd, | 1552 | core.in_fd, |
| 1626 | stdout_fd, | 1553 | core.out_fd, |
| 1627 | is_tty, | 1554 | core.is_tty, |
| 1628 | session_name, | 1555 | session_name, |
| 1629 | )) { | 1556 | )) { |
| 1630 | // Ctrl-\ during a reconnect: the user is done waiting, but | 1557 | // Ctrl-\ during a reconnect: the user is done waiting, but |
| @@ -1632,9 +1559,6 @@ fn session( | |||
| 1632 | exit_msg = "mux: detached while reconnecting (session still running; run mux to reattach)"; | 1559 | exit_msg = "mux: detached while reconnecting (session still running; run mux to reattach)"; |
| 1633 | return .{ .exit = 0 }; | 1560 | return .{ .exit = 0 }; |
| 1634 | } | 1561 | } |
| 1635 | // Only the caller knows a re-attach happened; the Replica's | ||
| 1636 | // contract says this clear is ours to do. | ||
| 1637 | rep.state_since_attach = false; | ||
| 1638 | // A reconnect ends the switch's grace along with the state it | 1562 | // A reconnect ends the switch's grace along with the state it |
| 1639 | // was measured against. Without this the property is permanent: | 1563 | // was measured against. Without this the property is permanent: |
| 1640 | // a link that drops hours later and comes back to a refusal | 1564 | // a link that drops hours later and comes back to a refusal |
| @@ -1648,11 +1572,7 @@ fn session( | |||
| 1648 | if (reconnect_grace_until == null) { | 1572 | if (reconnect_grace_until == null) { |
| 1649 | reconnect_grace_until = std.time.milliTimestamp() + reconnect_grace_ms; | 1573 | reconnect_grace_until = std.time.milliTimestamp() + reconnect_grace_ms; |
| 1650 | } | 1574 | } |
| 1651 | repaint_after_resync = true; | 1575 | core.reattached(); |
| 1652 | // Whatever was outstanding was predicted against a connection | ||
| 1653 | // that no longer exists. Dropping it is not an accusation, so | ||
| 1654 | // the counters stay where they are. | ||
| 1655 | overlay.flush(); | ||
| 1656 | // Same reasoning for a switch chord that was in flight: the | 1576 | // Same reasoning for a switch chord that was in flight: the |
| 1657 | // daemon it asked no longer has the question. The user presses | 1577 | // daemon it asked no longer has the question. The user presses |
| 1658 | // the chord again rather than being switched by a reply that | 1578 | // the chord again rather than being switched by a reply that |
| @@ -1662,32 +1582,14 @@ fn session( | |||
| 1662 | pending_switch.clear(); | 1582 | pending_switch.clear(); |
| 1663 | continue; | 1583 | continue; |
| 1664 | } | 1584 | } |
| 1665 | if (interact.winch_flag.swap(false, .acq_rel)) { | 1585 | if (core.winch(transport) == .lost) { |
| 1666 | if (interact.ttySize(stdout_fd)) |new_size| { | 1586 | needs_reconnect = true; |
| 1667 | if (new_size.cols != size.cols or new_size.rows != size.rows) { | 1587 | continue; |
| 1668 | // Only the local clip size changes here; the replica | ||
| 1669 | // follows the daemon, which answers the resize frame | ||
| 1670 | // with a snapshot carrying the new grid size. | ||
| 1671 | size = new_size; | ||
| 1672 | // Same rule as every other transport write: a dead | ||
| 1673 | // transport is reported, never thrown. | ||
| 1674 | transport.writeFrame( | ||
| 1675 | .resize, | ||
| 1676 | &proto.encodeSize(size.cols, size.rows), | ||
| 1677 | ) catch { | ||
| 1678 | needs_reconnect = true; | ||
| 1679 | continue; | ||
| 1680 | }; | ||
| 1681 | // The grid this would be painted on is about to stop | ||
| 1682 | // existing; cleared when the answering snapshot lands. | ||
| 1683 | overlay.setResizePending(true); | ||
| 1684 | } | ||
| 1685 | } | ||
| 1686 | } | 1588 | } |
| 1687 | 1589 | ||
| 1688 | var fds = [_]std.posix.pollfd{ | 1590 | var fds = [_]std.posix.pollfd{ |
| 1689 | .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, | 1591 | .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, |
| 1690 | .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, | 1592 | .{ .fd = if (stdin_open) core.in_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, |
| 1691 | }; | 1593 | }; |
| 1692 | _ = try std.posix.poll(&fds, transport.timeoutMs(100)); | 1594 | _ = try std.posix.poll(&fds, transport.timeoutMs(100)); |
| 1693 | transport.service(); | 1595 | transport.service(); |
| @@ -1701,18 +1603,11 @@ fn session( | |||
| 1701 | // with no newline discipline. Like `[reconnecting]` it sits in the | 1603 | // with no newline discipline. Like `[reconnecting]` it sits in the |
| 1702 | // corner until the next full repaint paints over it, which is the | 1604 | // corner until the next full repaint paints over it, which is the |
| 1703 | // right lifetime for a marker the user needs to actually read. | 1605 | // right lifetime for a marker the user needs to actually read. |
| 1704 | // ASCII only — `bannerText` places the label by byte length. | ||
| 1705 | if (pending_switch.expired(std.time.milliTimestamp())) { | 1606 | if (pending_switch.expired(std.time.milliTimestamp())) { |
| 1706 | if (is_tty) paint_mod.paintBanner(stdout_fd, size, "[no session list: upgrade muxd]"); | 1607 | core.banner("[no session list: upgrade muxd]"); |
| 1707 | } | 1608 | } |
| 1708 | 1609 | ||
| 1709 | // The idle path, and the only thing that can retire a prediction the | 1610 | try core.idle(); |
| 1710 | // application answered by going quiet: no frame is coming, so | ||
| 1711 | // reconcile will never run again and the glyph would otherwise stay | ||
| 1712 | // on screen for the rest of the session. | ||
| 1713 | if (overlay.expire(std.time.milliTimestamp()) == .contradicted and scroll_rows == 0) { | ||
| 1714 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); | ||
| 1715 | } | ||
| 1716 | 1611 | ||
| 1717 | // Labelled, because "no whole frame yet" must leave the REST of this | 1612 | // Labelled, because "no whole frame yet" must leave the REST of this |
| 1718 | // iteration running. Everything below — the detach chord, keystrokes, | 1613 | // iteration running. Everything below — the detach chord, keystrokes, |
| @@ -1746,75 +1641,11 @@ fn session( | |||
| 1746 | }, | 1641 | }, |
| 1747 | }; | 1642 | }; |
| 1748 | defer frame.deinit(alloc); | 1643 | defer frame.deinit(alloc); |
| 1749 | // The alternate screen waits for proof that the transport works. | 1644 | // Ahead of the switch and for EVERY frame type, not just the |
| 1750 | // Entering at setup would erase whatever the `--via` command wrote | 1645 | // ones that paint. The argument for both is long and it is at |
| 1751 | // to its inherited stderr (ssh reports auth and connection failures | 1646 | // `ownTerminal`; the one-line version is that this is what arms |
| 1752 | // hundreds of ms after spawn), and would blank the screen for the | 1647 | // the teardown, so nothing may write a mode before it. |
| 1753 | // whole of a hang like `mux --via "sleep 30"`. Autowrap goes off | 1648 | try core.ownTerminal(); |
| 1754 | // with it: an oversized grid row must clip at the right edge rather | ||
| 1755 | // than wrap and shift the whole paint. | ||
| 1756 | // | ||
| 1757 | // This must stay ahead of the frame switch and must run for EVERY | ||
| 1758 | // frame type, not just the ones that paint. `alt_screen` gates | ||
| 1759 | // the exit teardown, which is the only `?2004l` mux is certain | ||
| 1760 | // to write — a session that asks for bracketed paste and then | ||
| 1761 | // dies never sends the term_modes frame that would unset it. So | ||
| 1762 | // a term_modes handled while `alt_screen` was still false would | ||
| 1763 | // turn bracketing ON with nothing arranged to turn it off — the | ||
| 1764 | // user gets their terminal back still bracketing pastes long | ||
| 1765 | // after mux exited, with nothing on screen to say why. | ||
| 1766 | // | ||
| 1767 | // This comment is the whole defence, and no fixture can be | ||
| 1768 | // written to take over from it: `sendResync`'s `defer` puts | ||
| 1769 | // term_modes LAST, so no fixture driving the real daemon can | ||
| 1770 | // deliver one to a client that has not already seen a frame. | ||
| 1771 | // (A test with a hand-built frame stream could: `Transport` is | ||
| 1772 | // a `Conn` of two fds with `link` defaulting to `.fd`, so a | ||
| 1773 | // same-file test can construct one over a pipe. What stops it | ||
| 1774 | // is that the interesting path needs `is_tty`, i.e. a real pty | ||
| 1775 | // — which is why the coverage that exists is out-of-process in | ||
| 1776 | // test/ptyclient. So this is "nobody has written one", not | ||
| 1777 | // "one cannot exist".) The hazard is unreachable from outside | ||
| 1778 | // and one edit away from inside. | ||
| 1779 | // | ||
| 1780 | // Read it as two claims, because only the first is about | ||
| 1781 | // ordering. (1) This runs before any arm can write `?2004h`. | ||
| 1782 | // (2) `?2004l` is written only under `alt_screen`, so a | ||
| 1783 | // `?2004h` written outside it is one nothing will undo. | ||
| 1784 | // | ||
| 1785 | // Claim (2) used to be held by a race — `?2004h` went out | ||
| 1786 | // unconditionally, and on a tty this block simply always won. | ||
| 1787 | // It is now structural: `writeSideChannel` takes `alt_screen` | ||
| 1788 | // and refuses everything while it is false, so the set and its | ||
| 1789 | // undo are gated on one flag rather than on an ordering. That | ||
| 1790 | // closed a hole the race left open, and the title is how it was | ||
| 1791 | // found — see that function. A `--no-altscreen` or an inline | ||
| 1792 | // mode still needs its own teardown gate; what it no longer | ||
| 1793 | // needs is for this block to win a race first. | ||
| 1794 | // | ||
| 1795 | // The title push (`22;0t`) rides the same gate for the same | ||
| 1796 | // pairing argument, and needs it more: an unmatched POP does not | ||
| 1797 | // restore a title, it pops whatever the terminal had underneath | ||
| 1798 | // — somebody else's. Pushed here and popped in | ||
| 1799 | // `terminal_teardown`, both under `alt_screen`, is what makes | ||
| 1800 | // the pair exactly one deep. `0` is "icon name and window | ||
| 1801 | // title", matching the OSC 0 `appendTermTitle` writes. | ||
| 1802 | // | ||
| 1803 | // A push without a pop is a stack that only grows, so the | ||
| 1804 | // guarantee was checked rather than assumed: this write and the | ||
| 1805 | // pop are the only two in the tree, every exit from this | ||
| 1806 | // function is a `return` (no process.exit, no exec, no panic | ||
| 1807 | // here), and the reconnect path cannot push twice because it | ||
| 1808 | // re-enters the loop with `alt_screen` already true. What | ||
| 1809 | // escapes is a signal that kills the process outright — and | ||
| 1810 | // that loses `?1049l` and `?25h` with it, leaving the user on | ||
| 1811 | // an alternate screen with no cursor, so a title stack one | ||
| 1812 | // deeper is not the part they will notice. Same exposure as | ||
| 1813 | // every other line of the teardown, not a new one. | ||
| 1814 | if (is_tty and !alt_screen) { | ||
| 1815 | try proto.writeAllFd(stdout_fd, interact.terminal_setup); | ||
| 1816 | alt_screen = true; | ||
| 1817 | } | ||
| 1818 | switch (frame.type) { | 1649 | switch (frame.type) { |
| 1819 | .snapshot => { | 1650 | .snapshot => { |
| 1820 | // The Replica adopts seq/epoch/history, resizes to the | 1651 | // The Replica adopts seq/epoch/history, resizes to the |
| @@ -1823,7 +1654,7 @@ fn session( | |||
| 1823 | // snapshot proves nothing (BadPayload leaves everything | 1654 | // snapshot proves nothing (BadPayload leaves everything |
| 1824 | // untouched, state_since_attach included); anything | 1655 | // untouched, state_since_attach included); anything |
| 1825 | // else out of the resize stays loud. | 1656 | // else out of the resize stays loud. |
| 1826 | _ = rep.apply(.snapshot, frame.payload) catch |err| switch (err) { | 1657 | _ = core.rep.apply(.snapshot, frame.payload) catch |err| switch (err) { |
| 1827 | error.BadPayload => continue, | 1658 | error.BadPayload => continue, |
| 1828 | else => |e| return e, | 1659 | else => |e| return e, |
| 1829 | }; | 1660 | }; |
| @@ -1832,29 +1663,17 @@ fn session( | |||
| 1832 | // state proves it, and a shell that exits in the same | 1663 | // state proves it, and a shell that exits in the same |
| 1833 | // read as its snapshot must not lose its tile to a loop | 1664 | // read as its snapshot must not lose its tile to a loop |
| 1834 | // turn that never comes. | 1665 | // turn that never comes. |
| 1835 | recordOnState(&tile_recorded, &rep, alloc, target, session_name, wall_warned); | 1666 | recordOnState(&tile_recorded, &core.rep, alloc, target, session_name, wall_warned); |
| 1836 | reconnect_grace_until = null; | 1667 | reconnect_grace_until = null; |
| 1837 | // A snapshot answers a resize, ends a reconnect, and | 1668 | try core.snapshotTaken(); |
| 1838 | // rebuilds the screen under anything outstanding. None | ||
| 1839 | // of that says a prediction was wrong — it says we can | ||
| 1840 | // no longer find out, so the queue goes and the counters | ||
| 1841 | // do not move. | ||
| 1842 | overlay.setGrid(rep.grid.cols, rep.grid.rows); | ||
| 1843 | overlay.setResizePending(false); | ||
| 1844 | overlay.flush(); | ||
| 1845 | overlay.noteSeq(rep.last_seq); | ||
| 1846 | if (scroll_rows == 0) { | ||
| 1847 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); | ||
| 1848 | repaint_after_resync = false; // banner painted over | ||
| 1849 | } | ||
| 1850 | }, | 1669 | }, |
| 1851 | .delta => { | 1670 | .delta => { |
| 1852 | reconnect_grace_until = null; | 1671 | reconnect_grace_until = null; |
| 1853 | const applied = try rep.apply(.delta, frame.payload); | 1672 | const applied = try core.rep.apply(.delta, frame.payload); |
| 1854 | // Bound before the `.resync` branch, which can leave | 1673 | // Bound before the `.resync` branch, which can leave |
| 1855 | // this arm: a delta the replica accepted is state, and | 1674 | // this arm: a delta the replica accepted is state, and |
| 1856 | // state is what makes the attach history. | 1675 | // state is what makes the attach history. |
| 1857 | recordOnState(&tile_recorded, &rep, alloc, target, session_name, wall_warned); | 1676 | recordOnState(&tile_recorded, &core.rep, alloc, target, session_name, wall_warned); |
| 1858 | if (applied == .resync) { | 1677 | if (applied == .resync) { |
| 1859 | // A rejected delta means the replica can no longer be | 1678 | // A rejected delta means the replica can no longer be |
| 1860 | // trusted; ask for a fresh snapshot rather than | 1679 | // trusted; ask for a fresh snapshot rather than |
| @@ -1868,114 +1687,26 @@ fn session( | |||
| 1868 | // quoting a seq would invite the delta that cannot | 1687 | // quoting a seq would invite the delta that cannot |
| 1869 | // fix us. A reconnect quotes last_seq for exactly the | 1688 | // fix us. A reconnect quotes last_seq for exactly the |
| 1870 | // opposite reason: there, the replica is known good. | 1689 | // opposite reason: there, the replica is known good. |
| 1871 | sendAttach(transport, size, 0, 0, session_name) catch {}; | 1690 | sendAttach(transport, core.size, 0, 0, session_name) catch {}; |
| 1872 | continue; | 1691 | continue; |
| 1873 | } | 1692 | } |
| 1874 | // Judged against the replica the frame has just been fed | 1693 | try core.deltaTaken(frame.payload); |
| 1875 | // into, which is the only authority there is. | ||
| 1876 | const verdict = interact.reconcileOverlay( | ||
| 1877 | alloc, | ||
| 1878 | &overlay, | ||
| 1879 | rep.eng, | ||
| 1880 | rep.last_seq, | ||
| 1881 | std.time.milliTimestamp(), | ||
| 1882 | ); | ||
| 1883 | // While scrolled the replica still tracks live output; the | ||
| 1884 | // repaint on scroll exit comes from it. | ||
| 1885 | if (scroll_rows == 0) { | ||
| 1886 | if (repaint_after_resync or verdict == .contradicted) { | ||
| 1887 | // First frame back after a reconnect. The daemon | ||
| 1888 | // sent only what changed, which is correct — but | ||
| 1889 | // the screen still carries the banner, so repaint | ||
| 1890 | // the whole thing from the replica instead. | ||
| 1891 | // | ||
| 1892 | // A contradiction (or an expiry) takes the same | ||
| 1893 | // route: the whole queue has just been abandoned, | ||
| 1894 | // and repainting everything from the replica is | ||
| 1895 | // the simplest rollback that is certainly right. | ||
| 1896 | // It is affordable precisely because reconcile v2 | ||
| 1897 | // made contradictions rare — a burst outrunning | ||
| 1898 | // the round trip is no longer one. | ||
| 1899 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); | ||
| 1900 | repaint_after_resync = false; | ||
| 1901 | } else { | ||
| 1902 | try paint_mod.paintDeltaClipped(alloc, frame.payload, size, stdout_fd); | ||
| 1903 | } | ||
| 1904 | // Last, and after either paint: the rows the daemon | ||
| 1905 | // just sent have overwritten anything drawn on them, | ||
| 1906 | // including predictions that are still outstanding. | ||
| 1907 | interact.paintOverlay(alloc, &overlay, rep.eng.cursorPos(), size, stdout_fd); | ||
| 1908 | } | ||
| 1909 | }, | 1694 | }, |
| 1910 | .pty_mode => { | 1695 | .pty_mode => switch (try core.ptyModeChanged(frame.payload)) { |
| 1911 | const flags = proto.decodePtyMode(frame.payload) catch continue; | 1696 | .skip => continue, |
| 1912 | // Mode churn is ordinary — readline hands the terminal | 1697 | .carry_on => {}, |
| 1913 | // back and forth around every command — so the repaint | ||
| 1914 | // is spent only when the flush actually took something | ||
| 1915 | // off the screen. | ||
| 1916 | const had_pending = overlay.pendingCount() > 0; | ||
| 1917 | overlay.setMode(flags); | ||
| 1918 | if (had_pending and overlay.pendingCount() == 0 and scroll_rows == 0) { | ||
| 1919 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); | ||
| 1920 | } | ||
| 1921 | }, | 1698 | }, |
| 1922 | .scrollback_chunk => { | 1699 | .scrollback_chunk => switch (try core.scrollbackPage(frame.payload)) { |
| 1923 | if (scroll_rows == 0 or frame.payload.len < 6) continue; | 1700 | .skip => continue, |
| 1924 | try paint_mod.renderScrollback(alloc, frame.payload[6..], size, stdout_fd); | 1701 | .carry_on => {}, |
| 1925 | }, | ||
| 1926 | .term_event, .term_modes => { | ||
| 1927 | switch (semantic_core.receive(frame.type, frame.payload)) { | ||
| 1928 | .ignored => {}, | ||
| 1929 | .state => |state| { | ||
| 1930 | // Mode samples are deliberately not deduplicated. | ||
| 1931 | // sendResync repeats the current level on attach, | ||
| 1932 | // reconnect and forced re-attach; reasserting | ||
| 1933 | // DECSET/DECRST 2004 is a harmless level-set and | ||
| 1934 | // restores the host after a new connection. | ||
| 1935 | // Occurrence effects take the separate arm below | ||
| 1936 | // and are never covered by this repeat policy. | ||
| 1937 | try interact.writeSideChannel( | ||
| 1938 | alloc, | ||
| 1939 | stdout_fd, | ||
| 1940 | alt_screen, | ||
| 1941 | client_core.State, | ||
| 1942 | state, | ||
| 1943 | interact.appendTermState, | ||
| 1944 | ); | ||
| 1945 | }, | ||
| 1946 | .effect => |effect| try interact.writeSideChannel( | ||
| 1947 | alloc, | ||
| 1948 | stdout_fd, | ||
| 1949 | alt_screen, | ||
| 1950 | client_core.Effect, | ||
| 1951 | effect, | ||
| 1952 | interact.appendHostEffect, | ||
| 1953 | ), | ||
| 1954 | .reply => {}, | ||
| 1955 | } | ||
| 1956 | }, | ||
| 1957 | .term_title => { | ||
| 1958 | // Repeats are expected here for the same reason as | ||
| 1959 | // term_modes above — every attach resends the title — | ||
| 1960 | // and are harmless for a stronger reason: setting a | ||
| 1961 | // window title to the value it already holds is a | ||
| 1962 | // no-op with no counter or stack behind it. Note the | ||
| 1963 | // daemon never sends an empty one, so a repeat can | ||
| 1964 | // never clear a title the user is looking at. | ||
| 1965 | try interact.writeSideChannel( | ||
| 1966 | alloc, | ||
| 1967 | stdout_fd, | ||
| 1968 | alt_screen, | ||
| 1969 | []const u8, | ||
| 1970 | frame.payload, | ||
| 1971 | interact.appendTermTitle, | ||
| 1972 | ); | ||
| 1973 | }, | 1702 | }, |
| 1703 | .term_event, .term_modes => try core.semanticFrame(frame.type, frame.payload), | ||
| 1704 | .term_title => try core.titleFrame(frame.payload), | ||
| 1974 | .exit_status => { | 1705 | .exit_status => { |
| 1975 | // Before any session state, exit_status is almost always | 1706 | // Before any session state, exit_status is almost always |
| 1976 | // the daemon refusing the attach — say so, or it looks | 1707 | // the daemon refusing the attach — say so, or it looks |
| 1977 | // exactly like the shell itself exiting non-zero. | 1708 | // exactly like the shell itself exiting non-zero. |
| 1978 | if (!rep.state_since_attach) { | 1709 | if (!core.rep.state_since_attach) { |
| 1979 | // Right after a reconnect the usual cause is the | 1710 | // Right after a reconnect the usual cause is the |
| 1980 | // daemon not having reaped our dead predecessor's | 1711 | // daemon not having reaped our dead predecessor's |
| 1981 | // slot yet, so the session is full of *us*. Worth a | 1712 | // slot yet, so the session is full of *us*. Worth a |
| @@ -2048,183 +1779,60 @@ fn session( | |||
| 2048 | } | 1779 | } |
| 2049 | 1780 | ||
| 2050 | if (stdin_open and fds[1].revents != 0) { | 1781 | if (stdin_open and fds[1].revents != 0) { |
| 2051 | const n = std.posix.read(stdin_fd, &buf) catch 0; | 1782 | const cmd = core.readTyped() orelse { |
| 2052 | if (n == 0) { | ||
| 2053 | stdin_open = false; | 1783 | stdin_open = false; |
| 2054 | } else { | 1784 | continue; |
| 2055 | const cmd = prefix.feed(buf[0..n]); | 1785 | }; |
| 2056 | switch (cmd.action) { | 1786 | switch (cmd.action) { |
| 2057 | .none => {}, | 1787 | .none => {}, |
| 2058 | .detach => { | 1788 | .detach => { |
| 2059 | // Ctrl-\ d: detach and leave the session running. | 1789 | // Ctrl-\ d: detach and leave the session running. |
| 2060 | transport.writeFrame(.detach, "") catch {}; | 1790 | transport.writeFrame(.detach, "") catch {}; |
| 2061 | exit_msg = "mux: detached (session still running; run mux to reattach)"; | 1791 | exit_msg = "mux: detached (session still running; run mux to reattach)"; |
| 2062 | return .{ .exit = 0 }; | 1792 | return .{ .exit = 0 }; |
| 2063 | }, | 1793 | }, |
| 2064 | // `Ctrl-\ l` is the wall's last-zoomed-tile skip | 1794 | // `Ctrl-\ l` is the wall's last-zoomed-tile skip |
| 2065 | // (wallview.zig). A plain client has no "last tile" to | 1795 | // (wallview.zig). A plain client has no "last tile" to |
| 2066 | // go back to until phase 3 converges the two loops, so | 1796 | // go back to until phase 3 converges the two loops, so |
| 2067 | // the ACTION is swallowed here — the shared table knows | 1797 | // the ACTION is swallowed here — the shared table knows |
| 2068 | // the spelling, this caller has no meaning for it yet. | 1798 | // the spelling, this caller has no meaning for it yet. |
| 2069 | // The chord itself is not a no-op: like every other | 1799 | // The chord itself is not a no-op: like every other |
| 2070 | // chord it ends the chunk, so bytes typed behind it are | 1800 | // chord it ends the chunk, so bytes typed behind it are |
| 2071 | // dropped where an unknown command key would have let | 1801 | // dropped where an unknown command key would have let |
| 2072 | // them through. That is the rule `n`/`p`/`w` keep and | 1802 | // them through. That is the rule `n`/`p`/`w` keep and |
| 2073 | // the price of `l` being a chord at all. | 1803 | // the price of `l` being a chord at all. |
| 2074 | .last_session => {}, | 1804 | .last_session => {}, |
| 2075 | .new_session, .next_session, .prev_session, .wall => { | 1805 | .new_session, .next_session, .prev_session, .wall => { |
| 2076 | // Ctrl-\ c/n/p/w: which sessions exist is the | 1806 | // Ctrl-\ c/n/p/w: which sessions exist is the |
| 2077 | // daemon's to say, so every chord that depends on | 1807 | // daemon's to say, so every chord that depends on |
| 2078 | // the list waits for the answer rather than | 1808 | // the list waits for the answer rather than |
| 2079 | // guessing one. The `.sessions_reply` arm is where | 1809 | // guessing one. The `.sessions_reply` arm is where |
| 2080 | // this run actually ends — one question, and the | 1810 | // this run actually ends — one question, and the |
| 2081 | // intent is what tells it what to make of the | 1811 | // intent is what tells it what to make of the |
| 2082 | // answer. Armed with a deadline, because a daemon | 1812 | // answer. Armed with a deadline, because a daemon |
| 2083 | // that predates the question does not refuse it — | 1813 | // that predates the question does not refuse it — |
| 2084 | // see `PendingSwitch`. | 1814 | // see `PendingSwitch`. |
| 2085 | pending_switch.arm(switch (cmd.action) { | 1815 | pending_switch.arm(switch (cmd.action) { |
| 2086 | .new_session => .new, | 1816 | .new_session => .new, |
| 2087 | .next_session => .next, | 1817 | .next_session => .next, |
| 2088 | .prev_session => .prev, | 1818 | .prev_session => .prev, |
| 2089 | .wall => .wall, | 1819 | .wall => .wall, |
| 2090 | else => unreachable, | 1820 | else => unreachable, |
| 2091 | }, std.time.milliTimestamp()); | 1821 | }, std.time.milliTimestamp()); |
| 2092 | transport.writeFrame(.sessions_req, "") catch { | 1822 | transport.writeFrame(.sessions_req, "") catch { |
| 2093 | pending_switch.clear(); | 1823 | pending_switch.clear(); |
| 2094 | needs_reconnect = true; | ||
| 2095 | continue; | ||
| 2096 | }; | ||
| 2097 | }, | ||
| 2098 | } | ||
| 2099 | // Who the wheel belongs to is the session's to say, and it | ||
| 2100 | // says so in the modes it set: an application that asked | ||
| 2101 | // for mouse reporting gets every mouse byte verbatim, and | ||
| 2102 | // the filter is reset so a report straddling the handover | ||
| 2103 | // is not half-eaten. This is the same rule as bracketed | ||
| 2104 | // paste — the client mirrors what the session asked the | ||
| 2105 | // terminal for — applied to the one device the client also | ||
| 2106 | // has a use for. | ||
| 2107 | // | ||
| 2108 | // `alt_screen` gates it for a second reason, and it is the | ||
| 2109 | // one that bites hardest: it is the flag that says this | ||
| 2110 | // client took a terminal over and wrote | ||
| 2111 | // `client_mouse_setup` to it. A client whose stdin is a | ||
| 2112 | // PIPE never asked anyone for mouse reports, so nothing it | ||
| 2113 | // reads can be one — and filtering there is pure loss: the | ||
| 2114 | // bytes are whatever was piped in, and `\x1b[<64;10;5M` in | ||
| 2115 | // a heredoc is text a script meant to send. Measured: | ||
| 2116 | // `printf 'hello \x1b[<64;10;5M world\n' | mux` arrived at | ||
| 2117 | // the pty with the escape deleted. | ||
| 2118 | var keys = cmd.forward; | ||
| 2119 | var wheel: i32 = 0; | ||
| 2120 | if (!alt_screen or semantic_core.terminal_modes.appMouse()) { | ||
| 2121 | mouse.reset(); | ||
| 2122 | } else { | ||
| 2123 | const m = mouse.feed(cmd.forward, &mouse_buf); | ||
| 2124 | keys = m.forward; | ||
| 2125 | wheel = m.wheel; | ||
| 2126 | } | ||
| 2127 | |||
| 2128 | // Alternate scroll (tmux calls it that; DEC 1007 is the | ||
| 2129 | // terminal's own version). The alt screen has no scrollback | ||
| 2130 | // — `historyRows` returns 0 there by contract, so the rows | ||
| 2131 | // this notch would move do not exist — and a pager that did | ||
| 2132 | // not ask for the mouse is still the thing the wheel is | ||
| 2133 | // pointed at. So the notch becomes the arrow keys the pager | ||
| 2134 | // does understand. | ||
| 2135 | // | ||
| 2136 | // Without this the notch is CONSUMED and dropped: the | ||
| 2137 | // filter has already taken the bytes out, and the scroll | ||
| 2138 | // arithmetic below saturates at a history of zero. That was | ||
| 2139 | // measured on `less`, which is exactly the case this exists | ||
| 2140 | // for. | ||
| 2141 | // | ||
| 2142 | // Only at the live view: a client scrolled into history | ||
| 2143 | // before the session took the alt screen still owns its | ||
| 2144 | // wheel, and moving that view is what a notch there means. | ||
| 2145 | if (wheel != 0 and scroll_rows == 0 and rep.eng.onAltScreen()) { | ||
| 2146 | interact.sendAltScroll(transport, wheel, rep.eng.cursorKeys()) catch { | ||
| 2147 | needs_reconnect = true; | ||
| 2148 | continue; | ||
| 2149 | }; | ||
| 2150 | wheel = 0; // spent on the session, not on our view | ||
| 2151 | } | ||
| 2152 | |||
| 2153 | const scroll_up = "\x1b[5;2~"; // Shift+PageUp | ||
| 2154 | const scroll_dn = "\x1b[6;2~"; // Shift+PageDown | ||
| 2155 | const key_up = std.mem.eql(u8, keys, scroll_up); | ||
| 2156 | const key_dn = std.mem.eql(u8, keys, scroll_dn); | ||
| 2157 | // Rows to move back into history, from both devices at | ||
| 2158 | // once: a chunk can hold a notch and a keystroke, and | ||
| 2159 | // dropping either would be a scroll the user made and did | ||
| 2160 | // not get. The keys move a screenful, the wheel a few rows. | ||
| 2161 | var by: i64 = @as(i64, wheel) * interact.wheel_rows; | ||
| 2162 | if (key_up) by += size.rows; | ||
| 2163 | if (key_dn) by -= size.rows; | ||
| 2164 | // Whether this read began at the live view, remembered | ||
| 2165 | // before the scroll below can make it false. It decides | ||
| 2166 | // what a keystroke in the same read MEANS: see the exit | ||
| 2167 | // rule at the bottom of the block. | ||
| 2168 | const was_live = scroll_rows == 0; | ||
| 2169 | if (by != 0 or key_dn) { | ||
| 2170 | const next: u32 = if (by > 0) | ||
| 2171 | @min(scroll_rows +| @as(u32, @intCast(by)), rep.history_rows) | ||
| 2172 | else | ||
| 2173 | scroll_rows -| @as(u32, @intCast(-by)); | ||
| 2174 | // Shift+PageDown returns to live even when it was | ||
| 2175 | // already there, and that is load-bearing: it is the | ||
| 2176 | // only key that clears an overlay left in scroll mode | ||
| 2177 | // by a reconnect (see the note at the top of the loop). | ||
| 2178 | // The wheel does not get that arm — a notch at the live | ||
| 2179 | // view would repaint the whole screen for nothing. | ||
| 2180 | if (next == 0 and (scroll_rows > 0 or key_dn)) { | ||
| 2181 | scroll_rows = 0; | ||
| 2182 | overlay.setScrollMode(false); | ||
| 2183 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); | ||
| 2184 | } else if (next != scroll_rows) { | ||
| 2185 | scroll_rows = next; | ||
| 2186 | // The cursor is no longer where the user is looking, | ||
| 2187 | // so a prediction painted at it would land in the | ||
| 2188 | // middle of history. | ||
| 2189 | overlay.setScrollMode(true); | ||
| 2190 | interact.requestScrollPage(transport, &rep, scroll_rows, size) catch { | ||
| 2191 | needs_reconnect = true; | ||
| 2192 | continue; | ||
| 2193 | }; | ||
| 2194 | } | ||
| 2195 | } | ||
| 2196 | // A chunk that was nothing but a chord, a scroll key or a | ||
| 2197 | // wheel notch owes the pty nothing. This is the last block | ||
| 2198 | // in the loop body, so continuing is the same as falling | ||
| 2199 | // through. | ||
| 2200 | if (keys.len == 0 or key_up or key_dn) continue; | ||
| 2201 | if (scroll_rows > 0 and !was_live) { | ||
| 2202 | // Any other key exits scroll mode (swallowed, not forwarded). | ||
| 2203 | // | ||
| 2204 | // `was_live` is what keeps that rule honest when a notch | ||
| 2205 | // and a keystroke share one read. The exit rule is about | ||
| 2206 | // a key typed AT a history view; a key typed at the live | ||
| 2207 | // view milliseconds before the wheel moved it was typed | ||
| 2208 | // at the shell, and swallowing it loses input to a view | ||
| 2209 | // the user was not looking at yet. Both hands are | ||
| 2210 | // answered instead: the view moved, and the keystroke | ||
| 2211 | // goes where it was aimed. | ||
| 2212 | scroll_rows = 0; | ||
| 2213 | overlay.setScrollMode(false); | ||
| 2214 | try paint_mod.renderClipped(alloc, rep.eng, size, stdout_fd); | ||
| 2215 | } else { | ||
| 2216 | // Speculate before sending, so the glyph is on screen | ||
| 2217 | // while the keystroke is still in flight. The bytes that | ||
| 2218 | // go out are unchanged either way. | ||
| 2219 | interact.offerKeystroke(alloc, &overlay, rep.eng, keys, size, stdout_fd); | ||
| 2220 | transport.writeFrame(.input, keys) catch { | ||
| 2221 | // These keystrokes are lost with the transport, by | ||
| 2222 | // the same policy that drops what is typed while | ||
| 2223 | // disconnected. | ||
| 2224 | needs_reconnect = true; | 1824 | needs_reconnect = true; |
| 2225 | continue; | 1825 | continue; |
| 2226 | }; | 1826 | }; |
| 2227 | } | 1827 | }, |
| 1828 | } | ||
| 1829 | // Whatever was typed AHEAD of the chord is still owed to the | ||
| 1830 | // session — a chord ends the chunk, it does not swallow what | ||
| 1831 | // came before it. This is the last block in the loop body, so | ||
| 1832 | // the core's answer is the whole of what is left to do. | ||
| 1833 | if (try core.forward(transport, cmd.forward) == .lost) { | ||
| 1834 | needs_reconnect = true; | ||
| 1835 | continue; | ||
| 2228 | } | 1836 | } |
| 2229 | } | 1837 | } |
| 2230 | } | 1838 | } |
src/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -127,13 +127,13 @@ pub const PrefixFilter = struct { | |||
| 127 | 127 | ||
| 128 | /// One read of the session's stdin. The mouse filter's scratch is sized | 128 | /// One read of the session's stdin. The mouse filter's scratch is sized |
| 129 | /// from it, so they are one constant. | 129 | /// from it, so they are one constant. |
| 130 | pub const stdin_chunk = 16 * 1024; | 130 | const stdin_chunk = 16 * 1024; |
| 131 | 131 | ||
| 132 | /// How many rows one wheel notch moves the scrollback view. Three is what | 132 | /// How many rows one wheel notch moves the scrollback view. Three is what |
| 133 | /// every terminal's own scrollback does per notch, so it is what a user's | 133 | /// every terminal's own scrollback does per notch, so it is what a user's |
| 134 | /// hand already expects; a page per notch (the granularity the scroll KEYS | 134 | /// hand already expects; a page per notch (the granularity the scroll KEYS |
| 135 | /// use) overshoots so far that finding a line means hunting for it. | 135 | /// use) overshoots so far that finding a line means hunting for it. |
| 136 | pub const wheel_rows: u32 = 3; | 136 | const wheel_rows: u32 = 3; |
| 137 | 137 | ||
| 138 | /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones | 138 | /// Pulls SGR mouse reports out of the stdin stream and turns the wheel ones |
| 139 | /// into scrollback movement. Only runs while no application in the session | 139 | /// into scrollback movement. Only runs while no application in the session |
| @@ -153,10 +153,10 @@ pub const wheel_rows: u32 = 3; | |||
| 153 | /// leaks through as input. A terminal writes a report with one write, and | 153 | /// leaks through as input. A terminal writes a report with one write, and |
| 154 | /// the pty delivers up to 16 KiB per read, so that split is a theoretical | 154 | /// the pty delivers up to 16 KiB per read, so that split is a theoretical |
| 155 | /// one; a delayed Escape would be an every-session one. | 155 | /// one; a delayed Escape would be an every-session one. |
| 156 | pub const MouseFilter = struct { | 156 | const MouseFilter = struct { |
| 157 | /// Longest report worth holding: `ESC [ <` plus three parameters. A | 157 | /// Longest report worth holding: `ESC [ <` plus three parameters. A |
| 158 | /// candidate that outgrows it was never a mouse report. | 158 | /// candidate that outgrows it was never a mouse report. |
| 159 | pub const max_held = 24; | 159 | const max_held = 24; |
| 160 | 160 | ||
| 161 | const Out = struct { | 161 | const Out = struct { |
| 162 | /// The bytes that were not mouse reports, in order. | 162 | /// The bytes that were not mouse reports, in order. |
| @@ -168,7 +168,7 @@ pub const MouseFilter = struct { | |||
| 168 | held: [max_held]u8 = undefined, | 168 | held: [max_held]u8 = undefined, |
| 169 | len: usize = 0, | 169 | len: usize = 0, |
| 170 | 170 | ||
| 171 | pub fn reset(self: *MouseFilter) void { | 171 | fn reset(self: *MouseFilter) void { |
| 172 | self.len = 0; | 172 | self.len = 0; |
| 173 | } | 173 | } |
| 174 | 174 | ||
| @@ -176,7 +176,7 @@ pub const MouseFilter = struct { | |||
| 176 | /// `in.len + max_held` — a candidate held from the previous read is | 176 | /// `in.len + max_held` — a candidate held from the previous read is |
| 177 | /// handed back ahead of this chunk's bytes when it turns out not to | 177 | /// handed back ahead of this chunk's bytes when it turns out not to |
| 178 | /// have been a report after all. | 178 | /// have been a report after all. |
| 179 | pub fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out { | 179 | fn feed(self: *MouseFilter, in: []const u8, out: []u8) Out { |
| 180 | var kept: usize = 0; | 180 | var kept: usize = 0; |
| 181 | var wheel: i32 = 0; | 181 | var wheel: i32 = 0; |
| 182 | for (in) |b| { | 182 | for (in) |b| { |
| @@ -234,13 +234,13 @@ pub const MouseFilter = struct { | |||
| 234 | } | 234 | } |
| 235 | }; | 235 | }; |
| 236 | 236 | ||
| 237 | pub var winch_flag = std.atomic.Value(bool).init(false); | 237 | var winch_flag = std.atomic.Value(bool).init(false); |
| 238 | 238 | ||
| 239 | pub fn onWinch(_: c_int) callconv(.c) void { | 239 | fn onWinch(_: c_int) callconv(.c) void { |
| 240 | winch_flag.store(true, .release); | 240 | winch_flag.store(true, .release); |
| 241 | } | 241 | } |
| 242 | 242 | ||
| 243 | pub fn ttySize(fd: std.posix.fd_t) ?proto.Size { | 243 | fn ttySize(fd: std.posix.fd_t) ?proto.Size { |
| 244 | if (!std.posix.isatty(fd)) return null; | 244 | if (!std.posix.isatty(fd)) return null; |
| 245 | var ws: std.posix.winsize = undefined; | 245 | var ws: std.posix.winsize = undefined; |
| 246 | if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; | 246 | if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; |
| @@ -273,7 +273,7 @@ pub 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 | 273 | /// 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 | 274 | /// own modes arrive moments later in the first `term_modes` and level-set |
| 275 | /// these; this is what the wheel does until they do. | 275 | /// these; this is what the wheel does until they do. |
| 276 | pub const terminal_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++ client_mouse_setup; | 276 | const terminal_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l" ++ client_mouse_setup; |
| 277 | 277 | ||
| 278 | /// The mouse modes the client asks its own terminal for when no application | 278 | /// 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). | 279 | /// in the session wants them: button presses (1000) reported in SGR (1006). |
| @@ -337,7 +337,7 @@ fn inClientCapture(comptime dec: u16) bool { | |||
| 337 | /// terminal without the stack ignores both halves, which costs a title bar | 337 | /// terminal without the stack ignores both halves, which costs a title bar |
| 338 | /// left showing what the session set — the tmux behaviour, and the | 338 | /// left showing what the session set — the tmux behaviour, and the |
| 339 | /// fallback this would otherwise have shipped as. | 339 | /// fallback this would otherwise have shipped as. |
| 340 | pub const terminal_teardown = "\x1b[?2004l" ++ mouse_teardown ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l"; | 340 | const terminal_teardown = "\x1b[?2004l" ++ mouse_teardown ++ "\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l"; |
| 341 | 341 | ||
| 342 | /// Every mouse mode this client can ever have turned on, off. Built from | 342 | /// 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 | 343 | /// the wire table rather than typed out, because the set it has to undo is |
| @@ -351,7 +351,7 @@ const mouse_teardown = blk: { | |||
| 351 | }; | 351 | }; |
| 352 | 352 | ||
| 353 | /// Render validated terminal state as the DECSET/DECRST writes it implies. | 353 | /// Render validated terminal state as the DECSET/DECRST writes it implies. |
| 354 | pub fn appendTermState( | 354 | fn appendTermState( |
| 355 | out: *std.ArrayList(u8), | 355 | out: *std.ArrayList(u8), |
| 356 | alloc: std.mem.Allocator, | 356 | alloc: std.mem.Allocator, |
| 357 | state: client_core.State, | 357 | state: client_core.State, |
| @@ -410,7 +410,7 @@ fn appendMouseModes( | |||
| 410 | /// Built whole before the first byte is appended, like every other builder | 410 | /// Built whole before the first byte is appended, like every other builder |
| 411 | /// here: a rejection must not leave half an escape behind for a caller that | 411 | /// here: a rejection must not leave half an escape behind for a caller that |
| 412 | /// reuses one buffer across events. | 412 | /// reuses one buffer across events. |
| 413 | pub fn appendHostEffect( | 413 | fn appendHostEffect( |
| 414 | out: *std.ArrayList(u8), | 414 | out: *std.ArrayList(u8), |
| 415 | alloc: std.mem.Allocator, | 415 | alloc: std.mem.Allocator, |
| 416 | effect: client_core.Effect, | 416 | effect: client_core.Effect, |
| @@ -455,7 +455,7 @@ pub fn appendHostEffect( | |||
| 455 | /// — mux cannot read one back, and the engine cannot help either: ghostty's | 455 | /// — mux cannot read one back, and the engine cannot help either: ghostty's |
| 456 | /// terminal handler ignores title_push/title_pop outright, so the SESSION's | 456 | /// terminal handler ignores title_push/title_pop outright, so the SESSION's |
| 457 | /// title stack does not exist to be mirrored. | 457 | /// title stack does not exist to be mirrored. |
| 458 | pub fn appendTermTitle( | 458 | fn appendTermTitle( |
| 459 | out: *std.ArrayList(u8), | 459 | out: *std.ArrayList(u8), |
| 460 | alloc: std.mem.Allocator, | 460 | alloc: std.mem.Allocator, |
| 461 | payload: []const u8, | 461 | payload: []const u8, |
| @@ -510,7 +510,7 @@ pub fn appendTermTitle( | |||
| 510 | /// client: not something a stray clipboard byte gets to do. `Value` is | 510 | /// client: not something a stray clipboard byte gets to do. `Value` is |
| 511 | /// comptime for the same reason — it names the contract, and it lets each | 511 | /// comptime for the same reason — it names the contract, and it lets each |
| 512 | /// caller's value coerce to the type its builder actually declares. | 512 | /// caller's value coerce to the type its builder actually declares. |
| 513 | pub fn writeSideChannel( | 513 | fn writeSideChannel( |
| 514 | alloc: std.mem.Allocator, | 514 | alloc: std.mem.Allocator, |
| 515 | stdout_fd: std.posix.fd_t, | 515 | stdout_fd: std.posix.fd_t, |
| 516 | owns_terminal: bool, | 516 | owns_terminal: bool, |
| @@ -678,7 +678,7 @@ fn formatPredictStats(buf: []u8, c: predict.Counters) ![]const u8 { | |||
| 678 | 678 | ||
| 679 | pub const predict_stats_len = 192; | 679 | pub const predict_stats_len = 192; |
| 680 | 680 | ||
| 681 | pub fn dumpPredictStats(c: predict.Counters) void { | 681 | fn dumpPredictStats(c: predict.Counters) void { |
| 682 | const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return; | 682 | const want = std.posix.getenv("MUX_PREDICT_STATS") orelse return; |
| 683 | if (!std.mem.eql(u8, want, "1")) return; | 683 | if (!std.mem.eql(u8, want, "1")) return; |
| 684 | var buf: [predict_stats_len]u8 = undefined; | 684 | var buf: [predict_stats_len]u8 = undefined; |
| @@ -697,7 +697,7 @@ pub fn dumpPredictStats(c: predict.Counters) void { | |||
| 697 | /// Batched, because a spin arrives as one burst and one frame per arrow | 697 | /// Batched, because a spin arrives as one burst and one frame per arrow |
| 698 | /// would put a hundred frames on the wire for one flick of a finger. The | 698 | /// would put a hundred frames on the wire for one flick of a finger. The |
| 699 | /// loop is what bounds the buffer rather than the burst. | 699 | /// loop is what bounds the buffer rather than the burst. |
| 700 | pub fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void { | 700 | fn sendAltScroll(transport: anytype, wheel: i32, app_cursor: bool) !void { |
| 701 | const seq = altScrollSeq(wheel, app_cursor); | 701 | const seq = altScrollSeq(wheel, app_cursor); |
| 702 | var buf: [alt_scroll_batch * 3]u8 = undefined; | 702 | var buf: [alt_scroll_batch * 3]u8 = undefined; |
| 703 | var left: u32 = @as(u32, @intCast(@abs(wheel))) * wheel_rows; | 703 | var left: u32 = @as(u32, @intCast(@abs(wheel))) * wheel_rows; |
| @@ -728,7 +728,7 @@ fn altScrollSeq(wheel: i32, app_cursor: bool) *const [3]u8 { | |||
| 728 | return if (wheel > 0) "\x1b[A" else "\x1b[B"; | 728 | return if (wheel > 0) "\x1b[A" else "\x1b[B"; |
| 729 | } | 729 | } |
| 730 | 730 | ||
| 731 | pub fn requestScrollPage( | 731 | fn requestScrollPage( |
| 732 | transport: anytype, | 732 | transport: anytype, |
| 733 | rep: *const Replica, | 733 | rep: *const Replica, |
| 734 | rows_up: u32, | 734 | rows_up: u32, |
| @@ -740,6 +740,566 @@ pub fn requestScrollPage( | |||
| 740 | const start = rep.scrollStart(rows_up); | 740 | const start = rep.scrollStart(rows_up); |
| 741 | try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows)); | 741 | try transport.writeFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows)); |
| 742 | } | 742 | } |
| 743 | // ---- the interaction core ---------------------------------------------- | ||
| 744 | |||
| 745 | /// What a step that touched the transport found there. Reported rather than | ||
| 746 | /// thrown: a dead link is an ordinary event a driver answers by rebuilding | ||
| 747 | /// it, not an error that should unwind past the replica — the replica is | ||
| 748 | /// the whole reason a dropped link can be a non-event. | ||
| 749 | pub const Step = enum { ok, lost }; | ||
| 750 | |||
| 751 | /// Whether the driver should skip the rest of this pass over its loop. | ||
| 752 | /// `.skip` is a frame that changed nothing, which is exactly the `continue` | ||
| 753 | /// the session loop wrote before this was a return value. | ||
| 754 | pub const Pass = enum { carry_on, skip }; | ||
| 755 | |||
| 756 | /// 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 | ||
| 758 | /// prediction overlay drawn on top of it, the chord and mouse filters that | ||
| 759 | /// split what the user types, and the terminal this all happens on. | ||
| 760 | /// | ||
| 761 | /// How you drive it. The driver owns the transport and the loop; the Core | ||
| 762 | /// owns what happens at each event: | ||
| 763 | /// | ||
| 764 | /// * `init` / `deinit` — the Core owns its Engine, its overlay and, once | ||
| 765 | /// `takeTerminal` has run, the terminal's mode. `deinit` puts all three | ||
| 766 | /// back, terminal first so anything the driver prints afterwards lands | ||
| 767 | /// on the normal screen. | ||
| 768 | /// * `takeTerminal` once, then `ownTerminal` before handling ANY frame. | ||
| 769 | /// * per pass: `winch`, then `idle`. | ||
| 770 | /// * per frame: the driver routes by type. `rep.apply` is the driver's | ||
| 771 | /// call because the attach's own bookkeeping is interleaved with it; | ||
| 772 | /// everything the frame then means to the screen is `snapshotTaken`, | ||
| 773 | /// `deltaTaken`, `ptyModeChanged`, `scrollbackPage`, `semanticFrame`, | ||
| 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 | ||
| 778 | /// `forward` sends what is left. | ||
| 779 | /// * around a reconnect: `dropScrollView` before, `reattached` after. | ||
| 780 | /// | ||
| 781 | /// What it depends on: an Engine, a Replica, the prediction overlay, the | ||
| 782 | /// painter, and the shared semantic decoder — never a transport type. Every | ||
| 783 | /// method that writes takes the transport as `anytype` and calls exactly | ||
| 784 | /// one thing on it, `writeFrame`. | ||
| 785 | /// | ||
| 786 | /// Nothing here is a singleton. A wall tile that promotes into this core | ||
| 787 | /// brings its own transport and its own Core; `out_fd`/`size` are the only | ||
| 788 | /// two things that say where a Core paints, which is where a tile's | ||
| 789 | /// narrower paint sink will attach. | ||
| 790 | pub const Core = struct { | ||
| 791 | alloc: std.mem.Allocator, | ||
| 792 | /// The user's terminal. `in_fd` is the descriptor the driver polls and | ||
| 793 | /// `readTyped` reads; `out_fd` is everything this paints on. | ||
| 794 | in_fd: std.posix.fd_t, | ||
| 795 | out_fd: std.posix.fd_t, | ||
| 796 | /// Whether there is a terminal to own at all. A client whose stdin is a | ||
| 797 | /// pipe takes no raw mode, enters no alternate screen and asks nobody | ||
| 798 | /// for mouse reports — see `writeSideChannel` for what that costs. | ||
| 799 | is_tty: bool, | ||
| 800 | /// The local clip size. The replica follows the daemon's authoritative | ||
| 801 | /// grid, which under latest-wins may differ from this; paints are | ||
| 802 | /// clipped to this. | ||
| 803 | size: proto.Size, | ||
| 804 | /// The replay core (replica.zig). Public because the driver's own | ||
| 805 | /// business reads it — a reconnect quotes `last_seq`/`session_epoch`, | ||
| 806 | /// and a refusal is told from a shell exiting by `state_since_attach`. | ||
| 807 | /// The Core owns the Engine underneath it; the Replica only borrows it, | ||
| 808 | /// so `deinit` here is the one owner. | ||
| 809 | rep: Replica, | ||
| 810 | /// Speculative echo. Born `.never` and stays there until a daemon tells | ||
| 811 | /// it otherwise, so an old daemon that has never heard of pty_mode gets | ||
| 812 | /// a client that predicts nothing at all. | ||
| 813 | overlay: predict.Overlay, | ||
| 814 | /// Semantic terminal state and host effects are decoded once here, then | ||
| 815 | /// handed to the native adapters above. The web client owns another | ||
| 816 | /// instance of this same platform-neutral state machine. | ||
| 817 | semantic: client_core.ClientCore = .{}, | ||
| 818 | /// Chord state lives across reads, so it outlives one buffer. | ||
| 819 | prefix: PrefixFilter = .{}, | ||
| 820 | /// Mouse reports arrive in the same reads as keystrokes; this splits | ||
| 821 | /// them back out, across read boundaries. | ||
| 822 | mouse: MouseFilter = .{}, | ||
| 823 | /// Scroll mode: 0 = live; N = viewing the screenful whose bottom sits N | ||
| 824 | /// rows above live. Rows rather than pages because the wheel moves by a | ||
| 825 | /// few lines and the keys move by a screen — one unit that expresses | ||
| 826 | /// both, and `fetch_scrollback` already addresses absolute rows. | ||
| 827 | /// View state, not replay state, so it stays here rather than in the | ||
| 828 | /// Replica (which holds grid/seq/epoch/history — see replica.zig). | ||
| 829 | scroll_rows: u32 = 0, | ||
| 830 | /// Whether the alternate screen was entered — which is also the flag | ||
| 831 | /// that says this Core took the terminal over. See `ownTerminal`. | ||
| 832 | alt_screen: bool = false, | ||
| 833 | /// Null when there was no terminal to put into raw mode. | ||
| 834 | orig_termios: ?std.posix.termios = null, | ||
| 835 | /// The first paint after a reconnect must be a full one, so the | ||
| 836 | /// [reconnecting] banner goes away with everything else now stale. | ||
| 837 | repaint_after_resync: bool = false, | ||
| 838 | /// One read of stdin, and the mouse filter's scratch — sized to hold | ||
| 839 | /// one chunk plus whatever a previous read left mid-report (see | ||
| 840 | /// MouseFilter.feed). | ||
| 841 | in_buf: [stdin_chunk]u8 = undefined, | ||
| 842 | mouse_buf: [stdin_chunk + MouseFilter.max_held]u8 = undefined, | ||
| 843 | |||
| 844 | /// The terminal is measured here, not passed in: the Engine has to be | ||
| 845 | /// born at the size the first paint will be clipped to. | ||
| 846 | pub fn init( | ||
| 847 | alloc: std.mem.Allocator, | ||
| 848 | in_fd: std.posix.fd_t, | ||
| 849 | out_fd: std.posix.fd_t, | ||
| 850 | ) !Core { | ||
| 851 | const size = ttySize(out_fd) orelse proto.Size{ .cols = 80, .rows = 24 }; | ||
| 852 | const eng = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows }); | ||
| 853 | return .{ | ||
| 854 | .alloc = alloc, | ||
| 855 | .in_fd = in_fd, | ||
| 856 | .out_fd = out_fd, | ||
| 857 | .is_tty = std.posix.isatty(in_fd), | ||
| 858 | .size = size, | ||
| 859 | .rep = Replica.init(alloc, eng), | ||
| 860 | .overlay = predict.Overlay.init(alloc, size.cols, size.rows), | ||
| 861 | }; | ||
| 862 | } | ||
| 863 | |||
| 864 | /// The terminal goes back FIRST, so the stats line and whatever the | ||
| 865 | /// driver prints on its way out land on the normal screen rather than | ||
| 866 | /// the wiped alternate one. | ||
| 867 | pub fn deinit(self: *Core) void { | ||
| 868 | // Only undo what was actually done: leaving the alternate screen we | ||
| 869 | // never entered would wipe the user's own scrollback. | ||
| 870 | if (self.alt_screen) proto.writeAllFd(self.out_fd, terminal_teardown) catch {}; | ||
| 871 | if (self.orig_termios) |t| std.posix.tcsetattr(self.in_fd, .FLUSH, t) catch {}; | ||
| 872 | dumpPredictStats(self.overlay.counters); | ||
| 873 | self.overlay.deinit(); | ||
| 874 | self.rep.eng.deinit(); | ||
| 875 | } | ||
| 876 | |||
| 877 | /// Raw mode, and the SIGWINCH handler that makes `winch` reachable. | ||
| 878 | /// The alternate screen is NOT entered here — see `ownTerminal`. | ||
| 879 | pub fn takeTerminal(self: *Core) !void { | ||
| 880 | if (!self.is_tty) return; | ||
| 881 | const orig = try std.posix.tcgetattr(self.in_fd); | ||
| 882 | self.orig_termios = orig; | ||
| 883 | var raw = orig; | ||
| 884 | raw.lflag.ICANON = false; | ||
| 885 | raw.lflag.ECHO = false; | ||
| 886 | raw.lflag.ISIG = false; | ||
| 887 | raw.iflag.IXON = false; | ||
| 888 | raw.iflag.ICRNL = false; | ||
| 889 | try std.posix.tcsetattr(self.in_fd, .FLUSH, raw); | ||
| 890 | |||
| 891 | var sa: std.posix.Sigaction = .{ | ||
| 892 | .handler = .{ .handler = onWinch }, | ||
| 893 | .mask = std.posix.sigemptyset(), | ||
| 894 | .flags = 0, | ||
| 895 | }; | ||
| 896 | std.posix.sigaction(std.posix.SIG.WINCH, &sa, null); | ||
| 897 | } | ||
| 898 | |||
| 899 | /// Enter the alternate screen, once, on the first frame that proves the | ||
| 900 | /// transport works. | ||
| 901 | /// | ||
| 902 | /// Entering at setup would erase whatever the `--via` command wrote to its | ||
| 903 | /// inherited stderr (ssh reports auth and connection failures hundreds of | ||
| 904 | /// ms after spawn), and would blank the screen for the whole of a hang like | ||
| 905 | /// `mux --via "sleep 30"`. Autowrap goes off with it: an oversized grid row | ||
| 906 | /// must clip at the right edge rather than wrap and shift the whole paint. | ||
| 907 | /// | ||
| 908 | /// 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 | ||
| 910 | /// 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 | ||
| 912 | /// frame that would unset it. So a term_modes handled while `alt_screen` | ||
| 913 | /// 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 | ||
| 915 | /// after mux exited, with nothing on screen to say why. | ||
| 916 | /// | ||
| 917 | /// This comment is the whole defence, and no fixture can be written to take | ||
| 918 | /// over from it: `sendResync`'s `defer` puts term_modes LAST, so no fixture | ||
| 919 | /// driving the real daemon can deliver one to a client that has not already | ||
| 920 | /// seen a frame. (A test with a hand-built frame stream could: a Transport | ||
| 921 | /// is two fds, so a same-file test can construct one over a pipe. What | ||
| 922 | /// stops it is that the interesting path needs `is_tty`, i.e. a real pty — | ||
| 923 | /// which is why the coverage that exists is out-of-process in | ||
| 924 | /// test/ptyclient. So this is "nobody has written one", not "one cannot | ||
| 925 | /// exist".) The hazard is unreachable from outside and one edit away from | ||
| 926 | /// inside. | ||
| 927 | /// | ||
| 928 | /// 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 | ||
| 930 | /// only under `alt_screen`, so a `?2004h` written outside it is one nothing | ||
| 931 | /// will undo. | ||
| 932 | /// | ||
| 933 | /// 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: | ||
| 935 | /// `writeSideChannel` takes `alt_screen` and refuses everything while it is | ||
| 936 | /// 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 | ||
| 938 | /// was found — see that function. A `--no-altscreen` or an inline mode | ||
| 939 | /// still needs its own teardown gate; what it no longer needs is for this | ||
| 940 | /// to win a race first. | ||
| 941 | /// | ||
| 942 | /// 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, | ||
| 944 | /// it pops whatever the terminal had underneath — somebody else's. Pushed | ||
| 945 | /// here and popped in `terminal_teardown`, both under `alt_screen`, is what | ||
| 946 | /// makes the pair exactly one deep. `0` is "icon name and window title", | ||
| 947 | /// matching the OSC 0 `appendTermTitle` writes. | ||
| 948 | /// | ||
| 949 | /// A push without a pop is a stack that only grows, so the guarantee was | ||
| 950 | /// 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 | ||
| 952 | /// 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 | ||
| 954 | /// 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, | ||
| 956 | /// 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. | ||
| 958 | pub fn ownTerminal(self: *Core) !void { | ||
| 959 | if (self.is_tty and !self.alt_screen) { | ||
| 960 | try proto.writeAllFd(self.out_fd, terminal_setup); | ||
| 961 | self.alt_screen = true; | ||
| 962 | } | ||
| 963 | } | ||
| 964 | |||
| 965 | /// 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. | ||
| 967 | /// ASCII only: `bannerText` places the label by byte length. | ||
| 968 | pub fn banner(self: *Core, text: []const u8) void { | ||
| 969 | if (self.is_tty) paint_mod.paintBanner(self.out_fd, self.size, text); | ||
| 970 | } | ||
| 971 | |||
| 972 | /// Answer a SIGWINCH, if one arrived. Only the local clip size changes | ||
| 973 | /// here; the replica follows the daemon, which answers the resize frame | ||
| 974 | /// with a snapshot carrying the new grid size. | ||
| 975 | pub fn winch(self: *Core, transport: anytype) Step { | ||
| 976 | if (!winch_flag.swap(false, .acq_rel)) return .ok; | ||
| 977 | const new_size = ttySize(self.out_fd) orelse return .ok; | ||
| 978 | if (new_size.cols == self.size.cols and new_size.rows == self.size.rows) return .ok; | ||
| 979 | self.size = new_size; | ||
| 980 | // Same rule as every other transport write: a dead transport is | ||
| 981 | // reported, never thrown. | ||
| 982 | transport.writeFrame( | ||
| 983 | .resize, | ||
| 984 | &proto.encodeSize(new_size.cols, new_size.rows), | ||
| 985 | ) catch return .lost; | ||
| 986 | // The grid this would be painted on is about to stop existing; | ||
| 987 | // cleared when the answering snapshot lands. | ||
| 988 | self.overlay.setResizePending(true); | ||
| 989 | return .ok; | ||
| 990 | } | ||
| 991 | |||
| 992 | /// The idle path, and the only thing that can retire a prediction the | ||
| 993 | /// application answered by going quiet: no frame is coming, so | ||
| 994 | /// reconcile will never run again and the glyph would otherwise stay on | ||
| 995 | /// screen for the rest of the session. | ||
| 996 | pub fn idle(self: *Core) !void { | ||
| 997 | 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); | ||
| 999 | } | ||
| 1000 | } | ||
| 1001 | |||
| 1002 | /// The replica has taken a snapshot: tell the overlay and rebuild the | ||
| 1003 | /// screen under it. | ||
| 1004 | /// | ||
| 1005 | /// A snapshot answers a resize, ends a reconnect, and rebuilds the | ||
| 1006 | /// 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 | ||
| 1008 | /// the counters do not move. | ||
| 1009 | pub fn snapshotTaken(self: *Core) !void { | ||
| 1010 | self.overlay.setGrid(self.rep.grid.cols, self.rep.grid.rows); | ||
| 1011 | self.overlay.setResizePending(false); | ||
| 1012 | self.overlay.flush(); | ||
| 1013 | self.overlay.noteSeq(self.rep.last_seq); | ||
| 1014 | if (self.scroll_rows == 0) { | ||
| 1015 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); | ||
| 1016 | self.repaint_after_resync = false; // banner painted over | ||
| 1017 | } | ||
| 1018 | } | ||
| 1019 | |||
| 1020 | /// 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 | ||
| 1022 | /// deltas are what `paintDeltaClipped` draws. | ||
| 1023 | pub fn deltaTaken(self: *Core, payload: []const u8) !void { | ||
| 1024 | // Judged against the replica the frame has just been fed into, | ||
| 1025 | // which is the only authority there is. | ||
| 1026 | const verdict = reconcileOverlay( | ||
| 1027 | self.alloc, | ||
| 1028 | &self.overlay, | ||
| 1029 | self.rep.eng, | ||
| 1030 | self.rep.last_seq, | ||
| 1031 | std.time.milliTimestamp(), | ||
| 1032 | ); | ||
| 1033 | // While scrolled the replica still tracks live output; the repaint | ||
| 1034 | // on scroll exit comes from it. | ||
| 1035 | if (self.scroll_rows == 0) { | ||
| 1036 | if (self.repaint_after_resync or verdict == .contradicted) { | ||
| 1037 | // First frame back after a reconnect. The daemon sent only | ||
| 1038 | // what changed, which is correct — but the screen still | ||
| 1039 | // carries the banner, so repaint the whole thing from the | ||
| 1040 | // replica instead. | ||
| 1041 | // | ||
| 1042 | // A contradiction (or an expiry) takes the same route: the | ||
| 1043 | // whole queue has just been abandoned, and repainting | ||
| 1044 | // everything from the replica is the simplest rollback that | ||
| 1045 | // is certainly right. It is affordable precisely because | ||
| 1046 | // reconcile v2 made contradictions rare — a burst outrunning | ||
| 1047 | // the round trip is no longer one. | ||
| 1048 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); | ||
| 1049 | self.repaint_after_resync = false; | ||
| 1050 | } else { | ||
| 1051 | try paint_mod.paintDeltaClipped(self.alloc, payload, self.size, self.out_fd); | ||
| 1052 | } | ||
| 1053 | // Last, and after either paint: the rows the daemon just sent | ||
| 1054 | // have overwritten anything drawn on them, including predictions | ||
| 1055 | // that are still outstanding. | ||
| 1056 | paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.out_fd); | ||
| 1057 | } | ||
| 1058 | } | ||
| 1059 | |||
| 1060 | /// The session handed the terminal back and forth, or took it over. | ||
| 1061 | /// | ||
| 1062 | /// Mode churn is ordinary — readline does it around every command — so | ||
| 1063 | /// the repaint is spent only when the flush actually took something off | ||
| 1064 | /// the screen. | ||
| 1065 | pub fn ptyModeChanged(self: *Core, payload: []const u8) !Pass { | ||
| 1066 | const flags = proto.decodePtyMode(payload) catch return .skip; | ||
| 1067 | const had_pending = self.overlay.pendingCount() > 0; | ||
| 1068 | self.overlay.setMode(flags); | ||
| 1069 | 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); | ||
| 1071 | } | ||
| 1072 | return .carry_on; | ||
| 1073 | } | ||
| 1074 | |||
| 1075 | /// 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 | ||
| 1077 | /// painted over a screen it no longer describes. | ||
| 1078 | pub fn scrollbackPage(self: *Core, payload: []const u8) !Pass { | ||
| 1079 | if (self.scroll_rows == 0 or payload.len < 6) return .skip; | ||
| 1080 | try paint_mod.renderScrollback(self.alloc, payload[6..], self.size, self.out_fd); | ||
| 1081 | return .carry_on; | ||
| 1082 | } | ||
| 1083 | |||
| 1084 | /// A term_event or term_modes frame: decoded by the shared core, then | ||
| 1085 | /// rendered onto the host terminal by this platform's adapters. | ||
| 1086 | pub fn semanticFrame(self: *Core, frame_type: proto.MsgType, payload: []const u8) !void { | ||
| 1087 | switch (self.semantic.receive(frame_type, payload)) { | ||
| 1088 | .ignored => {}, | ||
| 1089 | .state => |state| { | ||
| 1090 | // Mode samples are deliberately not deduplicated. | ||
| 1091 | // sendResync repeats the current level on attach, reconnect | ||
| 1092 | // and forced re-attach; reasserting DECSET/DECRST 2004 is a | ||
| 1093 | // harmless level-set and restores the host after a new | ||
| 1094 | // connection. Occurrence effects take the separate arm below | ||
| 1095 | // and are never covered by this repeat policy. | ||
| 1096 | try writeSideChannel( | ||
| 1097 | self.alloc, | ||
| 1098 | self.out_fd, | ||
| 1099 | self.alt_screen, | ||
| 1100 | client_core.State, | ||
| 1101 | state, | ||
| 1102 | appendTermState, | ||
| 1103 | ); | ||
| 1104 | }, | ||
| 1105 | .effect => |effect| try writeSideChannel( | ||
| 1106 | self.alloc, | ||
| 1107 | self.out_fd, | ||
| 1108 | self.alt_screen, | ||
| 1109 | client_core.Effect, | ||
| 1110 | effect, | ||
| 1111 | appendHostEffect, | ||
| 1112 | ), | ||
| 1113 | .reply => {}, | ||
| 1114 | } | ||
| 1115 | } | ||
| 1116 | |||
| 1117 | /// The session's window title. | ||
| 1118 | /// | ||
| 1119 | /// Repeats are expected here for the same reason as term_modes above — | ||
| 1120 | /// every attach resends the title — and are harmless for a stronger | ||
| 1121 | /// reason: setting a window title to the value it already holds is a | ||
| 1122 | /// 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 | ||
| 1124 | /// looking at. | ||
| 1125 | pub fn titleFrame(self: *Core, payload: []const u8) !void { | ||
| 1126 | try writeSideChannel( | ||
| 1127 | self.alloc, | ||
| 1128 | self.out_fd, | ||
| 1129 | self.alt_screen, | ||
| 1130 | []const u8, | ||
| 1131 | payload, | ||
| 1132 | appendTermTitle, | ||
| 1133 | ); | ||
| 1134 | } | ||
| 1135 | |||
| 1136 | /// 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 | ||
| 1138 | /// descriptor and the driver stops polling it. | ||
| 1139 | /// | ||
| 1140 | /// The action is handed back rather than acted on, because what an | ||
| 1141 | /// action MEANS is the driver's — see `PrefixFilter`. Whatever was | ||
| 1142 | /// typed AHEAD of the chord is in `forward` and is still owed to the | ||
| 1143 | /// session, so a driver that does not end the run passes it to | ||
| 1144 | /// `forward` below. | ||
| 1145 | pub fn readTyped(self: *Core) ?PrefixFilter.Out { | ||
| 1146 | const n = std.posix.read(self.in_fd, &self.in_buf) catch 0; | ||
| 1147 | if (n == 0) return null; | ||
| 1148 | return self.prefix.feed(self.in_buf[0..n]); | ||
| 1149 | } | ||
| 1150 | |||
| 1151 | /// Everything that happens to typed bytes on their way to the session: | ||
| 1152 | /// the mouse split, alternate scroll, the scrollback view, and finally | ||
| 1153 | /// the prediction and the input frame. | ||
| 1154 | pub fn forward(self: *Core, transport: anytype, typed: []const u8) !Step { | ||
| 1155 | // Who the wheel belongs to is the session's to say, and it says so | ||
| 1156 | // in the modes it set: an application that asked for mouse | ||
| 1157 | // reporting gets every mouse byte verbatim, and the filter is reset | ||
| 1158 | // so a report straddling the handover is not half-eaten. This is | ||
| 1159 | // the same rule as bracketed paste — the client mirrors what the | ||
| 1160 | // session asked the terminal for — applied to the one device the | ||
| 1161 | // client also has a use for. | ||
| 1162 | // | ||
| 1163 | // `alt_screen` 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 | ||
| 1165 | // 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 | ||
| 1167 | // reads can be one — and filtering there is pure loss: the bytes | ||
| 1168 | // are whatever was piped in, and `\x1b[<64;10;5M` in a heredoc is | ||
| 1169 | // text a script meant to send. Measured: | ||
| 1170 | // `printf 'hello \x1b[<64;10;5M world\n' | mux` arrived at the pty | ||
| 1171 | // with the escape deleted. | ||
| 1172 | var keys = typed; | ||
| 1173 | var wheel: i32 = 0; | ||
| 1174 | if (!self.alt_screen or self.semantic.terminal_modes.appMouse()) { | ||
| 1175 | self.mouse.reset(); | ||
| 1176 | } else { | ||
| 1177 | const m = self.mouse.feed(typed, &self.mouse_buf); | ||
| 1178 | keys = m.forward; | ||
| 1179 | wheel = m.wheel; | ||
| 1180 | } | ||
| 1181 | |||
| 1182 | // Alternate scroll (tmux calls it that; DEC 1007 is the terminal's | ||
| 1183 | // own version). The alt screen has no scrollback — `historyRows` | ||
| 1184 | // returns 0 there by contract, so the rows this notch would move do | ||
| 1185 | // not exist — and a pager that did not ask for the mouse is still | ||
| 1186 | // the thing the wheel is pointed at. So the notch becomes the arrow | ||
| 1187 | // keys the pager does understand. | ||
| 1188 | // | ||
| 1189 | // Without this the notch is CONSUMED and dropped: the filter has | ||
| 1190 | // already taken the bytes out, and the scroll arithmetic below | ||
| 1191 | // saturates at a history of zero. That was measured on `less`, | ||
| 1192 | // which is exactly the case this exists for. | ||
| 1193 | // | ||
| 1194 | // Only at the live view: a client scrolled into history before the | ||
| 1195 | // session took the alt screen still owns its wheel, and moving that | ||
| 1196 | // view is what a notch there means. | ||
| 1197 | if (wheel != 0 and self.scroll_rows == 0 and self.rep.eng.onAltScreen()) { | ||
| 1198 | sendAltScroll(transport, wheel, self.rep.eng.cursorKeys()) catch return .lost; | ||
| 1199 | wheel = 0; // spent on the session, not on our view | ||
| 1200 | } | ||
| 1201 | |||
| 1202 | const scroll_up = "\x1b[5;2~"; // Shift+PageUp | ||
| 1203 | const scroll_dn = "\x1b[6;2~"; // Shift+PageDown | ||
| 1204 | const key_up = std.mem.eql(u8, keys, scroll_up); | ||
| 1205 | const key_dn = std.mem.eql(u8, keys, scroll_dn); | ||
| 1206 | // Rows to move back into history, from both devices at once: a | ||
| 1207 | // chunk can hold a notch and a keystroke, and dropping either would | ||
| 1208 | // be a scroll the user made and did not get. The keys move a | ||
| 1209 | // screenful, the wheel a few rows. | ||
| 1210 | var by: i64 = @as(i64, wheel) * wheel_rows; | ||
| 1211 | if (key_up) by += self.size.rows; | ||
| 1212 | if (key_dn) by -= self.size.rows; | ||
| 1213 | // Whether this read began at the live view, remembered before the | ||
| 1214 | // scroll below can make it false. It decides what a keystroke in | ||
| 1215 | // the same read MEANS: see the exit rule at the bottom. | ||
| 1216 | const was_live = self.scroll_rows == 0; | ||
| 1217 | if (by != 0 or key_dn) { | ||
| 1218 | const next: u32 = if (by > 0) | ||
| 1219 | @min(self.scroll_rows +| @as(u32, @intCast(by)), self.rep.history_rows) | ||
| 1220 | else | ||
| 1221 | self.scroll_rows -| @as(u32, @intCast(-by)); | ||
| 1222 | // Shift+PageDown returns to live even when it was already | ||
| 1223 | // there, and that is load-bearing: it is the only key that | ||
| 1224 | // clears an overlay left in scroll mode by a reconnect (see | ||
| 1225 | // `dropScrollView`). The wheel does not get that arm — a notch | ||
| 1226 | // at the live view would repaint the whole screen for nothing. | ||
| 1227 | if (next == 0 and (self.scroll_rows > 0 or key_dn)) { | ||
| 1228 | self.scroll_rows = 0; | ||
| 1229 | self.overlay.setScrollMode(false); | ||
| 1230 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); | ||
| 1231 | } else if (next != self.scroll_rows) { | ||
| 1232 | self.scroll_rows = next; | ||
| 1233 | // The cursor is no longer where the user is looking, so a | ||
| 1234 | // prediction painted at it would land in the middle of | ||
| 1235 | // history. | ||
| 1236 | self.overlay.setScrollMode(true); | ||
| 1237 | requestScrollPage(transport, &self.rep, self.scroll_rows, self.size) catch return .lost; | ||
| 1238 | } | ||
| 1239 | } | ||
| 1240 | // A chunk that was nothing but a chord, a scroll key or a wheel | ||
| 1241 | // notch owes the pty nothing. | ||
| 1242 | if (keys.len == 0 or key_up or key_dn) return .ok; | ||
| 1243 | if (self.scroll_rows > 0 and !was_live) { | ||
| 1244 | // Any other key exits scroll mode (swallowed, not forwarded). | ||
| 1245 | // | ||
| 1246 | // `was_live` is what keeps that rule honest when a notch and a | ||
| 1247 | // keystroke share one read. The exit rule is about a key typed | ||
| 1248 | // AT a history view; a key typed at the live view milliseconds | ||
| 1249 | // before the wheel moved it was typed at the shell, and | ||
| 1250 | // swallowing it loses input to a view the user was not looking | ||
| 1251 | // at yet. Both hands are answered instead: the view moved, and | ||
| 1252 | // the keystroke goes where it was aimed. | ||
| 1253 | self.scroll_rows = 0; | ||
| 1254 | self.overlay.setScrollMode(false); | ||
| 1255 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); | ||
| 1256 | } else { | ||
| 1257 | // Speculate before sending, so the glyph is on screen while the | ||
| 1258 | // keystroke is still in flight. The bytes that go out are | ||
| 1259 | // unchanged either way. | ||
| 1260 | offerKeystroke(self.alloc, &self.overlay, self.rep.eng, keys, self.size, self.out_fd); | ||
| 1261 | transport.writeFrame(.input, keys) catch return .lost; | ||
| 1262 | // These keystrokes are lost with the transport, by the same | ||
| 1263 | // policy that drops what is typed while disconnected. | ||
| 1264 | } | ||
| 1265 | return .ok; | ||
| 1266 | } | ||
| 1267 | |||
| 1268 | /// Go back to the live view without painting it — what a driver does | ||
| 1269 | /// BEFORE a reconnect, where the resync's own repaint is what will | ||
| 1270 | /// arrive. | ||
| 1271 | /// | ||
| 1272 | /// A resync repaints live state, so a history page would be silently | ||
| 1273 | /// replaced a moment later — and the banner would sit over stale rows | ||
| 1274 | /// until the user happened to leave scroll mode. | ||
| 1275 | /// | ||
| 1276 | /// The overlay has to be told, and only the driver can tell it: | ||
| 1277 | /// `flush()` drops predictions but deliberately leaves the mode bit | ||
| 1278 | /// alone, so a reconnect taken while scrolled would leave the overlay | ||
| 1279 | /// suppressing with no page to suppress for. The "any other key" exit | ||
| 1280 | /// in `forward` cannot rescue it — that branch is guarded by | ||
| 1281 | /// `scroll_rows > 0`, which the line below has just made false. | ||
| 1282 | /// Shift+PageDown still can (its `scroll_rows == 0` arm clears the mode | ||
| 1283 | /// unconditionally), so this is recoverable rather than terminal — but | ||
| 1284 | /// only by a keystroke the user has no reason to guess, so prediction | ||
| 1285 | /// is silently off until they do. | ||
| 1286 | pub fn dropScrollView(self: *Core) void { | ||
| 1287 | self.scroll_rows = 0; | ||
| 1288 | self.overlay.setScrollMode(false); | ||
| 1289 | } | ||
| 1290 | |||
| 1291 | /// A new transport is up and an attach frame has gone out on it. | ||
| 1292 | /// | ||
| 1293 | /// Only the driver knows a re-attach happened; the Replica's contract | ||
| 1294 | /// says this clear is its caller's to do. Whatever the overlay held was | ||
| 1295 | /// predicted against a connection that no longer exists — dropping it | ||
| 1296 | /// is not an accusation, so the counters stay where they are. | ||
| 1297 | pub fn reattached(self: *Core) void { | ||
| 1298 | self.rep.state_since_attach = false; | ||
| 1299 | self.repaint_after_resync = true; | ||
| 1300 | self.overlay.flush(); | ||
| 1301 | } | ||
| 1302 | }; | ||
| 743 | 1303 | ||
| 744 | test "interact: a chord in one read detaches and forwards what preceded it" { | 1304 | test "interact: a chord in one read detaches and forwards what preceded it" { |
| 745 | var f: PrefixFilter = .{}; | 1305 | var f: PrefixFilter = .{}; |