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
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 |