a73x

fdb2f5c4

feat: a saved local line comes back in the browser too

a73x   2026-08-26 06:51

Commit message
feat: a saved local line comes back in the browser too

A browser tile attaches at 0x0 by the passivity contract, which the
daemon reads as join-only: after a restart the saved wall was a grid of
dead local tiles the CLI would have brought back. The hub now births the
session on a side connection and re-dials, so the browser sees the tear
narration it already handles and re-attaches at 0x0 as always. Remote
lines are untouched — the refusal is forwarded, as the CLI shows
[refused].

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