fdb2f5c4
feat: a saved local line comes back in the browser too
a73x 2026-08-26 06:51
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -93,9 +93,12 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`. | |||
| 93 | there is no `unrecordTile`. `--via` and `muxa` record nothing; `mux wall`'s | 93 | there is no `unrecordTile`. `--via` and `muxa` record nothing; `mux wall`'s |
| 94 | argv is a view, not an attach. A RESTORED line attaches-or-creates when it | 94 | argv is a view, not an attach. A RESTORED line attaches-or-creates when it |
| 95 | names the local socket and joins only when it is remote | 95 | names the local socket and joins only when it is remote |
| 96 | (`wallview.hydratedCreates`): a daemon restart must not leave the user's own | 96 | (`client.hydratedCreates`, one owner for the CLI and the hub): a daemon |
| 97 | wall a grid of `[refused]`, and a saved file must not spawn a shell on | 97 | restart must not leave the user's own wall a grid of `[refused]`, and a |
| 98 | another host. | 98 | saved file must not spawn a shell on another host. The hub reaches the |
| 99 | same rule from the other side — it cannot claim a size, so it births the | ||
| 100 | session on a side connection when an attach is refused before any grid, | ||
| 101 | and a session the user ENDED stays ended. | ||
| 99 | - **The layout sidecar is derived convenience, not authored intent.** | 102 | - **The layout sidecar is derived convenience, not authored intent.** |
| 100 | `$XDG_STATE_HOME/mux/layout` stores the pane tree on last detach, hydrated | 103 | `$XDG_STATE_HOME/mux/layout` stores the pane tree on last detach, hydrated |
| 101 | walls only (no-argv `mux wall` or post-fold; argv walls and `mux TARGET` | 104 | walls only (no-argv `mux wall` or post-fold; argv walls and `mux TARGET` |
src/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -994,6 +994,14 @@ pub fn hydratedCreates(target: Target) bool { | |||
| 994 | return target == .sock; | 994 | return target == .sock; |
| 995 | } | 995 | } |
| 996 | 996 | ||
| 997 | // The grid a birth asks for. It is `main.Opts`'s own default — the size | ||
| 998 | // `muxd run` gives session 0 — because a session created for a client | ||
| 999 | // that claims no size has to be born at SOMETHING, and the daemon's own | ||
| 1000 | // answer to that question is the one nobody has to explain. Wrong, and a | ||
| 1001 | // restored browser tile comes back at a shape no client ever asked for. | ||
| 1002 | pub const birth_cols: u16 = 80; | ||
| 1003 | pub const birth_rows: u16 = 24; | ||
| 1004 | |||
| 997 | // How long a birth may take before the caller is told nothing happened. | 1005 | // How long a birth may take before the caller is told nothing happened. |
| 998 | // Generous for a fork+exec on a local socket and short enough that a | 1006 | // Generous for a fork+exec on a local socket and short enough that a |
| 999 | // browser tile is not left mute: past this the refusal is forwarded and | 1007 | // browser tile is not left mute: past this the refusal is forwarded and |
src/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -52,6 +52,10 @@ pub fn wsTileId(path: []const u8) ?u32 { | |||
| 52 | return std.fmt.parseInt(u32, path[prefix.len..], 10) catch null; | 52 | return std.fmt.parseInt(u32, path[prefix.len..], 10) catch null; |
| 53 | } | 53 | } |
| 54 | 54 | ||
| 55 | /// What a pump needs to run a tile without holding the tile itself. The | ||
| 56 | /// session name rides along because the pump may have to CREATE it. | ||
| 57 | pub const Checkout = struct { target: client.Target, session: []const u8 }; | ||
| 58 | |||
| 55 | /// One runtime tile. Owns an arena holding its resolved target and label | 59 | /// One runtime tile. Owns an arena holding its resolved target and label |
| 56 | /// strings; the arena dies with the tile, which is why pumps must | 60 | /// strings; the arena dies with the tile, which is why pumps must |
| 57 | /// checkout a COPY rather than borrow. | 61 | /// checkout a COPY rather than borrow. |
| @@ -344,19 +348,22 @@ pub const Hub = struct { | |||
| 344 | 348 | ||
| 345 | /// UnknownId vs OutOfMemory: the HTTP layer answers 404 for one, | 349 | /// UnknownId vs OutOfMemory: the HTTP layer answers 404 for one, |
| 346 | /// 500 for the other. | 350 | /// 500 for the other. |
| 347 | pub fn checkoutTarget( | 351 | pub fn checkoutTile( |
| 348 | self: *Hub, | 352 | self: *Hub, |
| 349 | id: u32, | 353 | id: u32, |
| 350 | ws_fd: std.posix.fd_t, | 354 | ws_fd: std.posix.fd_t, |
| 351 | arena: std.mem.Allocator, | 355 | arena: std.mem.Allocator, |
| 352 | ) error{ UnknownId, OutOfMemory }!client.Target { | 356 | ) error{ UnknownId, OutOfMemory }!Checkout { |
| 353 | self.mutex.lock(); | 357 | self.mutex.lock(); |
| 354 | defer self.mutex.unlock(); | 358 | defer self.mutex.unlock(); |
| 355 | 359 | ||
| 356 | const idx = self.indexOf(id) orelse return error.UnknownId; | 360 | const idx = self.indexOf(id) orelse return error.UnknownId; |
| 357 | // Copy first: a failed copy must not leave an fd registered for a | 361 | // Copy first: a failed copy must not leave an fd registered for a |
| 358 | // pump that never starts. | 362 | // pump that never starts. |
| 359 | const copy = try copyTarget(arena, self.tiles.items[idx].target); | 363 | const copy = Checkout{ |
| 364 | .target = try copyTarget(arena, self.tiles.items[idx].target), | ||
| 365 | .session = try arena.dupe(u8, self.tiles.items[idx].session), | ||
| 366 | }; | ||
| 360 | // FIRST registration wins: two browsers on one wall run two pumps | 367 | // FIRST registration wins: two browsers on one wall run two pumps |
| 361 | // per tile, and overwriting would leave removeTile able to wake | 368 | // per tile, and overwriting would leave removeTile able to wake |
| 362 | // only the last one — worse, the untracked pump's release would | 369 | // only the last one — worse, the untracked pump's release would |
| @@ -713,6 +720,7 @@ pub fn pumpTile( | |||
| 713 | ws: *std.http.Server.WebSocket, | 720 | ws: *std.http.Server.WebSocket, |
| 714 | ws_fd: std.posix.fd_t, | 721 | ws_fd: std.posix.fd_t, |
| 715 | target_in: client.Target, | 722 | target_in: client.Target, |
| 723 | session: []const u8, | ||
| 716 | ) void { | 724 | ) void { |
| 717 | var target = target_in; | 725 | var target = target_in; |
| 718 | // No terminal to spam and a control channel that already narrates: | 726 | // No terminal to spam and a control channel that already narrates: |
| @@ -720,6 +728,31 @@ pub fn pumpTile( | |||
| 720 | // same way reconnect() quiets retries. | 728 | // same way reconnect() quiets retries. |
| 721 | if (target == .hand) target.hand.report_fallback = false; | 729 | if (target == .hand) target.hand.report_fallback = false; |
| 722 | 730 | ||
| 731 | // The restore rule's state. Only `saw_grid` is per-DIAL — it is the | ||
| 732 | // browser's own discriminator (mux.js, the exit_status case): before | ||
| 733 | // any grid an exit_status is the daemon refusing the attach, after | ||
| 734 | // one it is the shell exiting. The other two outlive the connection | ||
| 735 | // ON PURPOSE, because every refusal closes it: `serviceObserver` | ||
| 736 | // answers an unseated attach with exit_status and then `dropObserver`, | ||
| 737 | // so a flag cleared on `.closed` bounds nothing at all. | ||
| 738 | // | ||
| 739 | // `ended` is wallview's "exited stays exited" (its pump ENDS on an | ||
| 740 | // exit_status rather than redialing). Without it a user typing `exit` | ||
| 741 | // gets a new shell: the daemon reaps the session and closes, the hub | ||
| 742 | // redials, mux.js re-attaches on `up`, the attach is refused because | ||
| 743 | // the session is gone — and that is indistinguishable from the | ||
| 744 | // restore case unless the pump remembers it already watched this | ||
| 745 | // session die. | ||
| 746 | // | ||
| 747 | // `birth_tried` bounds a daemon that refuses the birth too (a full | ||
| 748 | // table, a name it will not make) to ONE attempt per healthy period: | ||
| 749 | // it is cleared by a grid arriving, not by a re-dial, so a second | ||
| 750 | // daemon restart still heals while a refuse/redial spin cannot fork a | ||
| 751 | // process per turn. | ||
| 752 | var saw_grid = false; | ||
| 753 | var birth_tried = false; | ||
| 754 | var ended = false; | ||
| 755 | |||
| 723 | var live = Liveness.init(); | 756 | var live = Liveness.init(); |
| 724 | ws.writeMessage(controlMessage(.connecting), .binary) catch return; | 757 | ws.writeMessage(controlMessage(.connecting), .binary) catch return; |
| 725 | var transport = dialLoop(alloc, target, ws, ws_fd, &live) orelse return; | 758 | var transport = dialLoop(alloc, target, ws, ws_fd, &live) orelse return; |
| @@ -750,6 +783,7 @@ pub fn pumpTile( | |||
| 750 | .incomplete => break :frames, | 783 | .incomplete => break :frames, |
| 751 | .closed => { | 784 | .closed => { |
| 752 | if (!redial(alloc, &transport, target, ws, ws_fd, &live)) return; | 785 | if (!redial(alloc, &transport, target, ws, ws_fd, &live)) return; |
| 786 | saw_grid = false; | ||
| 753 | // fds[1].revents describes a socket state from | 787 | // fds[1].revents describes a socket state from |
| 754 | // BEFORE the re-dial, and dialLoop may have eaten | 788 | // BEFORE the re-dial, and dialLoop may have eaten |
| 755 | // the very message it described. Re-poll instead | 789 | // the very message it described. Re-poll instead |
| @@ -761,6 +795,40 @@ pub fn pumpTile( | |||
| 761 | }, | 795 | }, |
| 762 | }; | 796 | }; |
| 763 | defer frame.deinit(alloc); | 797 | defer frame.deinit(alloc); |
| 798 | if (frame.type == .snapshot or frame.type == .delta) { | ||
| 799 | saw_grid = true; | ||
| 800 | birth_tried = false; | ||
| 801 | } | ||
| 802 | if (frame.type == .exit_status and saw_grid) ended = true; | ||
| 803 | // A saved LOCAL line whose session the daemon no longer | ||
| 804 | // has: the CLI wall recreates it, and a browser wall that | ||
| 805 | // did not would be a grid of dead tiles after every | ||
| 806 | // reboot. The tile itself cannot ask — it attaches at 0x0 | ||
| 807 | // by the passivity contract, which is join-only — so the | ||
| 808 | // hub births the session on a connection of its own and | ||
| 809 | // re-dials. The browser re-attaches on `up` and never | ||
| 810 | // learns a refusal happened, which is why nothing in | ||
| 811 | // mux.js decides any of this. | ||
| 812 | if (frame.type == .exit_status and !saw_grid and !ended and !birth_tried and | ||
| 813 | client.hydratedCreates(target)) | ||
| 814 | { | ||
| 815 | birth_tried = true; | ||
| 816 | if (client.birthSession( | ||
| 817 | alloc, | ||
| 818 | target, | ||
| 819 | session, | ||
| 820 | client.birth_cols, | ||
| 821 | client.birth_rows, | ||
| 822 | )) |_| { | ||
| 823 | if (!redial(alloc, &transport, target, ws, ws_fd, &live)) return; | ||
| 824 | saw_grid = false; | ||
| 825 | continue :outer; | ||
| 826 | } else |_| { | ||
| 827 | // A birth the daemon refused too (a full table, a | ||
| 828 | // name it will not make). The refusal below is | ||
| 829 | // forwarded exactly as it was before any of this. | ||
| 830 | } | ||
| 831 | } | ||
| 764 | var hdr: [proto.frame_header_len]u8 = undefined; | 832 | var hdr: [proto.frame_header_len]u8 = undefined; |
| 765 | hdr[0] = @intFromEnum(frame.type); | 833 | hdr[0] = @intFromEnum(frame.type); |
| 766 | std.mem.writeInt(u32, hdr[1..5], @intCast(frame.payload.len), .little); | 834 | std.mem.writeInt(u32, hdr[1..5], @intCast(frame.payload.len), .little); |
| @@ -927,7 +995,7 @@ pub fn serveConn( | |||
| 927 | // One name for the fd we register with, so the release below | 995 | // One name for the fd we register with, so the release below |
| 928 | // provably names the same number it checked out under. | 996 | // provably names the same number it checked out under. |
| 929 | const ws_fd = stream.handle; | 997 | const ws_fd = stream.handle; |
| 930 | const target = hub.checkoutTarget(id, ws_fd, pump_arena.allocator()) catch |err| switch (err) { | 998 | const checked = hub.checkoutTile(id, ws_fd, pump_arena.allocator()) catch |err| switch (err) { |
| 931 | // Removed between the page's GET and this dial: the browser | 999 | // Removed between the page's GET and this dial: the browser |
| 932 | // refetches /tiles and stops asking for it. | 1000 | // refetches /tiles and stops asking for it. |
| 933 | error.UnknownId => { | 1001 | error.UnknownId => { |
| @@ -947,7 +1015,7 @@ pub fn serveConn( | |||
| 947 | defer hub.releaseTile(id, ws_fd); | 1015 | defer hub.releaseTile(id, ws_fd); |
| 948 | var ws = req.respondWebSocket(.{ .key = key }) catch return; | 1016 | var ws = req.respondWebSocket(.{ .key = key }) catch return; |
| 949 | ws.flush() catch return; | 1017 | ws.flush() catch return; |
| 950 | pumpTile(alloc, &ws, ws_fd, target); | 1018 | pumpTile(alloc, &ws, ws_fd, checked.target, checked.session); |
| 951 | return; | 1019 | return; |
| 952 | } | 1020 | } |
| 953 | 1021 | ||
| @@ -1176,13 +1244,34 @@ test "hub: checkout copies the target into the caller's arena; release unregiste | |||
| 1176 | 1244 | ||
| 1177 | var arena = std.heap.ArenaAllocator.init(alloc); | 1245 | var arena = std.heap.ArenaAllocator.init(alloc); |
| 1178 | defer arena.deinit(); | 1246 | defer arena.deinit(); |
| 1179 | const t = try hub.checkoutTarget(0, 7, arena.allocator()); | 1247 | const t = try hub.checkoutTile(0, 7, arena.allocator()); |
| 1180 | // The copy must survive the tile's death: remove frees the tile's own | 1248 | // The copy must survive the tile's death: remove frees the tile's own |
| 1181 | // arena, and the pump's strings must not be in it. | 1249 | // arena, and the pump's strings must not be in it. |
| 1182 | try hub.removeTile(0); | 1250 | try hub.removeTile(0); |
| 1183 | try std.testing.expectEqualStrings("/tmp/a", t.sock); | 1251 | try std.testing.expectEqualStrings("/tmp/a", t.target.sock); |
| 1184 | hub.releaseTile(0, 7); // gone id: a no-op, not a crash | 1252 | hub.releaseTile(0, 7); // gone id: a no-op, not a crash |
| 1185 | try std.testing.expectError(error.UnknownId, hub.checkoutTarget(0, 7, arena.allocator())); | 1253 | try std.testing.expectError(error.UnknownId, hub.checkoutTile(0, 7, arena.allocator())); |
| 1254 | } | ||
| 1255 | |||
| 1256 | test "hub: a checkout carries the tile's session name, which the pump may have to create" { | ||
| 1257 | const alloc = std.testing.allocator; | ||
| 1258 | var w = wall.Wall{}; | ||
| 1259 | _ = try w.add(alloc, "--sock /tmp/a#wghost"); | ||
| 1260 | _ = try w.add(alloc, "--sock /tmp/a"); | ||
| 1261 | var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default); | ||
| 1262 | defer hub.deinit(); | ||
| 1263 | |||
| 1264 | var arena = std.heap.ArenaAllocator.init(alloc); | ||
| 1265 | defer arena.deinit(); | ||
| 1266 | // Without the name the pump could only ever create the default | ||
| 1267 | // session, which is not the one the browser tile is asking for. | ||
| 1268 | const named = try hub.checkoutTile(0, 7, arena.allocator()); | ||
| 1269 | try std.testing.expectEqualStrings("wghost", named.session); | ||
| 1270 | // A spelling with no `#NAME` rides the wire as the empty tail, which | ||
| 1271 | // is the default session — `proto.wireName`'s own convention, kept | ||
| 1272 | // rather than rebuilt here. | ||
| 1273 | const bare = try hub.checkoutTile(1, 8, arena.allocator()); | ||
| 1274 | try std.testing.expectEqualStrings("", bare.session); | ||
| 1186 | } | 1275 | } |
| 1187 | 1276 | ||
| 1188 | test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" { | 1277 | test "hub: two pumps on one tile — the first fd stays tracked, the second's release spares it" { |
| @@ -1196,10 +1285,10 @@ test "hub: two pumps on one tile — the first fd stays tracked, the second's re | |||
| 1196 | defer arena.deinit(); | 1285 | defer arena.deinit(); |
| 1197 | 1286 | ||
| 1198 | // Two browsers on one wall: both pumps get a target and both serve. | 1287 | // Two browsers on one wall: both pumps get a target and both serve. |
| 1199 | const first = try hub.checkoutTarget(0, 7, arena.allocator()); | 1288 | const first = try hub.checkoutTile(0, 7, arena.allocator()); |
| 1200 | const second = try hub.checkoutTarget(0, 8, arena.allocator()); | 1289 | const second = try hub.checkoutTile(0, 8, arena.allocator()); |
| 1201 | try std.testing.expectEqualStrings("/tmp/a", first.sock); | 1290 | try std.testing.expectEqualStrings("/tmp/a", first.target.sock); |
| 1202 | try std.testing.expectEqualStrings("/tmp/a", second.sock); | 1291 | try std.testing.expectEqualStrings("/tmp/a", second.target.sock); |
| 1203 | 1292 | ||
| 1204 | // The first registration is the tracked one, and the untracked pump's | 1293 | // The first registration is the tracked one, and the untracked pump's |
| 1205 | // release must leave it alone — clearing it would leave removeTile | 1294 | // release must leave it alone — clearing it would leave removeTile |