a73x

c4179e9c

fix: a reconnect is nobody's ask — the pump spends it on the dial that got the link

a73x   2026-08-28 19:53

Commit message
fix: a reconnect is nobody's ask — the pump spends it on the dial that got the link

src/client.zig
Old New
@@ -1579,16 +1579,37 @@ test "openHandoff: a start that does not help is tried once — a second announc
1579 var start_buf: [512]u8 = undefined; 1579 var start_buf: [512]u8 = undefined;
1580 const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()}); 1580 const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()});
1581 1581
1582 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ 1582 const h: HandoffTarget = .{
1583 .host = "fake", 1583 .host = "fake",
1584 .ssh_cmd = ssh_cmd, 1584 .ssh_cmd = ssh_cmd,
1585 .start_cmd = start_cmd, 1585 .start_cmd = start_cmd,
1586 .cache_path = null, 1586 .cache_path = null,
1587 .deadline_ms = 200, 1587 .deadline_ms = 200,
1588 } }, &carry, std.posix.STDIN_FILENO)); 1588 };
1589 try std.testing.expectError(
1590 error.UnterminatedLine,
1591 Transport.open(alloc, .{ .hand = h }, &carry, std.posix.STDIN_FILENO),
1592 );
1589 1593
1590 try std.testing.expect(try shimMade(tmp.path(), "started")); 1594 try std.testing.expect(try shimMade(tmp.path(), "started"));
1591 try std.testing.expectEqual(@as(u64, 2), try shimRuns(tmp.path(), "runs")); 1595 try std.testing.expectEqual(@as(u64, 2), try shimRuns(tmp.path(), "runs"));
1596
1597 // The SECOND dial, with the value a pump holds once it has spent the
1598 // ask — `wallview.pumpTile` clears this bit after the dial that got the
1599 // link, and hands the cleared copy to every `redial`. Without that, a
1600 // tile whose box was stopped would restart the daemon on every backoff
1601 // for as long as it lived: the poll's bug, moved onto a tile.
1602 var again = h;
1603 again.asked = false;
1604 var start_path: [512]u8 = undefined;
1605 try std.fs.cwd().deleteFile(try std.fmt.bufPrint(&start_path, "{s}/started", .{tmp.path()}));
1606 try std.testing.expectError(
1607 error.UnterminatedLine,
1608 Transport.open(alloc, .{ .hand = again }, &carry, std.posix.STDIN_FILENO),
1609 );
1610 try std.testing.expect(!try shimMade(tmp.path(), "started"));
1611 // Three, not four: the second dial ran the ssh line and stopped.
1612 try std.testing.expectEqual(@as(u64, 3), try shimRuns(tmp.path(), "runs"));
1592 } 1613 }
1593 1614
1594 test "lostMsg: only a --via transport that never connected gets the new wording" { 1615 test "lostMsg: only a --via transport that never connected gets the new wording" {
src/wallview.zig
Old New
@@ -1310,9 +1310,10 @@ fn pumpTile(t: *Tile) void {
1310 1310
1311 // Whether this tile may narrate and may start a daemon travels IN its 1311 // Whether this tile may narrate and may start a daemon travels IN its
1312 // target, set once by whoever made the tile: a picker Enter is an ask, 1312 // target, set once by whoever made the tile: a picker Enter is an ask,
1313 // a poll's list is not. `dial` spends it on the first attempt, so a 1313 // a poll's list is not. This is the pump's own copy — `t.r.target` is
1314 // reconnect neither prints nor starts anything. 1314 // read by the KEYBOARD thread under `paint_mu` for chord births and is
1315 const target = t.r.target; 1315 // never written from here — and it is spent below, once.
1316 var target = t.r.target;
1316 1317
1317 // ONE Core per tile, from birth. It owns this tile's replica, its 1318 // ONE Core per tile, from birth. It owns this tile's replica, its
1318 // prediction overlay and its drag for the tile's whole life: the tile 1319 // prediction overlay and its drag for the tile's whole life: the tile
@@ -1365,6 +1366,14 @@ fn pumpTile(t: *Tile) void {
1365 break :blk tr; 1366 break :blk tr;
1366 } else dial(alloc, t, target) orelse return; 1367 } else dial(alloc, t, target) orelse return;
1367 defer transport.close(); 1368 defer transport.close();
1369 // The ask is SPENT, on whichever of the two branches above got the
1370 // link: the entry tile's dial happened on the main thread, a picker
1371 // birth's just happened here. Every `redial` below is handed this
1372 // copy, so a reconnect can neither start a daemon — `muxd stop` typed
1373 // on that box would otherwise be undone by the next backoff, the
1374 // poll's bug moved onto a tile — nor print the fallback line onto the
1375 // alternate screen the tiles are painted on.
1376 if (target == .hand) target.hand.asked = false;
1368 // The entry tile's attach carries its rect, so the session is sized to 1377 // The entry tile's attach carries its rect, so the session is sized to
1369 // the terminal the tile claims and no second resize follows — 1378 // the terminal the tile claims and no second resize follows —
1370 // re-asserting a size the daemon just heard costs one more snapshot on 1379 // re-asserting a size the daemon just heard costs one more snapshot on
@@ -2506,7 +2515,12 @@ fn addSessionTile(
2506 name: []const u8, 2515 name: []const u8,
2507 place: Place, 2516 place: Place,
2508 ) FocusTo { 2517 ) FocusTo {
2509 const target = tiles[from].r.target; 2518 // The parent's ask does not descend. A chord is a session on a daemon
2519 // this wall is already attached to, so there is nothing here to start;
2520 // inheriting the bit would hand every descendant of a picker-born tile
2521 // a permission nobody asked for, for the rest of its life.
2522 var target = tiles[from].r.target;
2523 if (target == .hand) target.hand.asked = false;
2510 const want = proto.resolveName(name); 2524 const want = proto.resolveName(name);
2511 for (tiles[0..live.*], present[0..live.*], 0..) |*t, p, i| { 2525 for (tiles[0..live.*], present[0..live.*], 0..) |*t, p, i| {
2512 if (!p) continue; 2526 if (!p) continue;
src/webhub.zig
Old New
@@ -111,13 +111,20 @@ fn resolveTile(
111 .host => |h| blk: { 111 .host => |h| blk: {
112 const hd = try arena.dupe(u8, h); 112 const hd = try arena.dupe(u8, h);
113 const r = try handoff.recipeFor(arena, hd, false); 113 const r = try handoff.recipeFor(arena, hd, false);
114 break :blk .{ .hand = .{ 114 break :blk .{
115 .host = hd, 115 .hand = .{
116 .ssh_cmd = r.ssh_cmd, 116 .host = hd,
117 .start_cmd = r.start_cmd, 117 .ssh_cmd = r.ssh_cmd,
118 .cache_path = r.cache_path, 118 .start_cmd = r.start_cmd,
119 .idle_ms = idle_ms, 119 .cache_path = r.cache_path,
120 } }; 120 .idle_ms = idle_ms,
121 // The hub is never the ask: a tile redials for as long as
122 // the page is open and nobody is sitting in front of it.
123 // Said HERE and not only where the pump clears it — a
124 // permission cleared downstream is one a new road can miss.
125 .asked = false,
126 },
127 };
121 }, 128 },
122 .quic => |hp| blk: { 129 .quic => |hp| blk: {
123 const key_path = switch (xdg.resolveKeyPath(arena, key) catch |err| switch (err) { 130 const key_path = switch (xdg.resolveKeyPath(arena, key) catch |err| switch (err) {
test/e2e_09_hosts.sh
Old New
@@ -944,6 +944,45 @@ dump_session "$NSOCKB" 0 | grep -q 'ns-born-ok' || {
944 echo "e2e FAIL: no-start: box B's session 0 never ran the marker:" 944 echo "e2e FAIL: no-start: box B's session 0 never ran the marker:"
945 "$MUXD" stats --sock "$NSOCKB"; exit 1; } 945 "$MUXD" stats --sock "$NSOCKB"; exit 1; }
946 946
947 # (3b) A picker-born tile RECONNECTS, and a reconnect is nobody's ask. The
948 # ask buys one dial; if it survived into the redial loop, the poll's bug
949 # would simply have moved onto a tile — `muxd stop` typed on box B would be
950 # undone by the tile's next backoff, forever, and the fallback line would
951 # print onto the alternate screen once a cycle.
952 #
953 # A second wall, because the daemon-side oracles above have to run while
954 # that daemon is still alive. This one opens the picker on a wall that
955 # already has box B's session 0 as a tile, births a SECOND session there
956 # (so the tile under test is picker-born, not list-born — a list-born tile
957 # was never asked for and would pass this vacuously), and then stops the
958 # daemon from inside its own shell. `muxd stop` dies with the shell it
959 # killed; that is fine, the request was already sent, and the assertion
960 # below is on the process table rather than on that command's word.
961 set +e
962 SHELL=/bin/sh XDG_STATE_HOME="$NSTATE" PATH="$NPATH" timeout 90 "$PTYCLIENT" \
963 --cols 100 --rows 30 --out "$OUT.nscap2" --err "$OUT.nscap2.err" -- \
964 "$MUX" > "$OUT.nspc2" 2>&1 <<EOF
965 expect ns-born-ok 25000
966 send \x1cs
967 expect Enter/c new session 15000
968 send 2
969 settle 500 15000
970 send \r
971 settle 2000 30000
972 send printf 'ns-two-%s\n' ok\n
973 expect ns-two-ok 25000
974 send muxd stop --sock $NSOCKB\n
975 settle 6000 30000
976 send \x1cd
977 waitexit 15000
978 EOF
979 RC=$?
980 set -e
981 [ "$RC" -eq 0 ] || {
982 echo "e2e FAIL: no-start: the reconnect leg exited $RC:"
983 cat "$OUT.nspc2"; echo "--- stderr ---"; cat "$OUT.nscap2.err"; exit 1; }
984 nostart_empty "$NRUNB" "$NSOCKB" "a picker-born tile reconnecting after muxd stop"
985
947 # (4) A cold `mux HOST` entry, on the box the wall left alone. One daemon, 986 # (4) A cold `mux HOST` entry, on the box the wall left alone. One daemon,
948 # and the client said so in its own voice — `muxd start`'s progress reaches 987 # and the client said so in its own voice — `muxd start`'s progress reaches
949 # the user's terminal over the same inherited stderr ssh's own diagnostics 988 # the user's terminal over the same inherited stderr ssh's own diagnostics