a73x

2c2dbcec

fix: HandoffTarget.asked defaults to false — a forgotten field must not start a daemon

a73x   2026-08-28 19:53

Commit message
fix: HandoffTarget.asked defaults to false — a forgotten field must not start a daemon

src/cli/mux_main.zig
Old New
@@ -356,13 +356,19 @@ pub fn main() !u8 {
356 // tiles from the same call. 356 // tiles from the same call.
357 const r = try handoff.recipeFor(alloc, h.name, false); 357 const r = try handoff.recipeFor(alloc, h.name, false);
358 defer r.deinit(alloc); 358 defer r.deinit(alloc);
359 return wallview.runAttach(alloc, .{ .hand = .{ 359 return wallview.runAttach(alloc, .{
360 .host = h.name, 360 .hand = .{
361 .ssh_cmd = r.ssh_cmd, 361 .host = h.name,
362 .start_cmd = r.start_cmd, 362 .ssh_cmd = r.ssh_cmd,
363 .cache_path = r.cache_path, 363 .start_cmd = r.start_cmd,
364 .idle_ms = h.idle_ms, 364 .cache_path = r.cache_path,
365 } }, h.session, null, h.idle_ms, h.agent); 365 .idle_ms = h.idle_ms,
366 // The entry dial: `mux HOST` is the user asking, in person,
367 // for that box — the one place a start and a fallback line
368 // are owed to somebody who is sitting there waiting.
369 .asked = true,
370 },
371 }, h.session, null, h.idle_ms, h.agent);
366 }, 372 },
367 .attach => |t| { 373 .attach => |t| {
368 if (t.via) |cmd| return wallview.runAttach( 374 if (t.via) |cmd| return wallview.runAttach(
src/client.zig
Old New
@@ -220,7 +220,12 @@ pub const HandoffTarget = struct {
220 /// says nothing at all about it. The user who wants to know can detach 220 /// says nothing at all about it. The user who wants to know can detach
221 /// and reattach, which reports it on the next attach; the alternative 221 /// and reattach, which reports it on the next attach; the alternative
222 /// is noise on every backoff cycle for as long as the link stays bad. 222 /// is noise on every backoff cycle for as long as the link stays bad.
223 asked: bool = true, 223 ///
224 /// Defaults to the harmless half: a dial path that forgets the field
225 /// gets it wrong SILENTLY either way, and of the two silences a missing
226 /// start is a `mux HOST` that says so, while a spurious one is a daemon
227 /// on someone else's box that nothing reports at all.
228 asked: bool = false,
224 /// Whether the caller owns a screen the start's progress would land on. 229 /// Whether the caller owns a screen the start's progress would land on.
225 /// `muxd start` writes `starting\u{2026}`, a dot per interval and an 230 /// `muxd start` writes `starting\u{2026}`, a dot per interval and an
226 /// up-line to stderr; under the wall's alternate screen those bytes sit 231 /// up-line to stderr; under the wall's alternate screen those bytes sit
@@ -1568,6 +1573,37 @@ fn shimMade(dir: []const u8, name: []const u8) !bool {
1568 return true; 1573 return true;
1569 } 1574 }
1570 1575
1576 test "openHandoff: a HandoffTarget nobody configured starts nothing" {
1577 // Seven dial paths spell `.asked = false` and one spells `true`. The
1578 // default is the value a NEW path inherits by forgetting the line, and
1579 // nothing fails loudly when it does: the symptom is a daemon (and a
1580 // shell in session 0) appearing on someone else's box. So the default
1581 // is the harmless half, and the ask is what has to be written down.
1582 const alloc = std.testing.allocator;
1583 var carry: std.ArrayList(u8) = .empty;
1584 defer carry.deinit(alloc);
1585 var stdin = try FakeStdin.install("");
1586 defer stdin.deinit();
1587 var tmp = try TmpDir.make();
1588 defer tmp.cleanup();
1589
1590 var ssh_buf: [512]u8 = undefined;
1591 const ssh_cmd = try std.fmt.bufPrint(&ssh_buf, "echo x >>{s}/runs; exit 1", .{tmp.path()});
1592 var start_buf: [512]u8 = undefined;
1593 const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()});
1594
1595 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{
1596 .host = "fake",
1597 .ssh_cmd = ssh_cmd,
1598 .start_cmd = start_cmd,
1599 .cache_path = null,
1600 .deadline_ms = 200,
1601 } }, &carry, std.posix.STDIN_FILENO));
1602
1603 try std.testing.expect(!try shimMade(tmp.path(), "started"));
1604 try std.testing.expectEqual(@as(u64, 1), try shimRuns(tmp.path(), "runs"));
1605 }
1606
1571 test "openHandoff: the dial a user ASKED for starts the daemon it did not find, and re-runs the ssh line exactly once" { 1607 test "openHandoff: the dial a user ASKED for starts the daemon it did not find, and re-runs the ssh line exactly once" {
1572 // `muxd endpoint` on a box with no daemon exits 1 having written 1608 // `muxd endpoint` on a box with no daemon exits 1 having written
1573 // nothing, which arrives here as an announce that never terminated. 1609 // nothing, which arrives here as an announce that never terminated.
@@ -1598,6 +1634,7 @@ test "openHandoff: the dial a user ASKED for starts the daemon it did not find,
1598 .start_cmd = start_cmd, 1634 .start_cmd = start_cmd,
1599 .cache_path = null, 1635 .cache_path = null,
1600 .deadline_ms = 200, 1636 .deadline_ms = 200,
1637 .asked = true,
1601 } }, &carry, std.posix.STDIN_FILENO); 1638 } }, &carry, std.posix.STDIN_FILENO);
1602 defer t.close(); 1639 defer t.close();
1603 1640
@@ -1665,6 +1702,7 @@ test "openHandoff: an ssh still alive after its stdout closed keeps its own exit
1665 .start_cmd = start_cmd, 1702 .start_cmd = start_cmd,
1666 .cache_path = null, 1703 .cache_path = null,
1667 .deadline_ms = 200, 1704 .deadline_ms = 200,
1705 .asked = true,
1668 } }, &carry, std.posix.STDIN_FILENO); 1706 } }, &carry, std.posix.STDIN_FILENO);
1669 defer t.close(); 1707 defer t.close();
1670 1708
@@ -1696,6 +1734,7 @@ test "openHandoff: a start that does not help is tried once — a second announc
1696 .start_cmd = start_cmd, 1734 .start_cmd = start_cmd,
1697 .cache_path = null, 1735 .cache_path = null,
1698 .deadline_ms = 200, 1736 .deadline_ms = 200,
1737 .asked = true,
1699 }; 1738 };
1700 try std.testing.expectError( 1739 try std.testing.expectError(
1701 error.UnterminatedLine, 1740 error.UnterminatedLine,
@@ -1749,6 +1788,7 @@ test "openHandoff: ssh's own failure is not a box without a daemon — no start,
1749 .start_cmd = start_cmd, 1788 .start_cmd = start_cmd,
1750 .cache_path = null, 1789 .cache_path = null,
1751 .deadline_ms = 200, 1790 .deadline_ms = 200,
1791 .asked = true,
1752 } }, &carry, std.posix.STDIN_FILENO)); 1792 } }, &carry, std.posix.STDIN_FILENO));
1753 1793
1754 try std.testing.expect(!try shimMade(tmp.path(), "started")); 1794 try std.testing.expect(!try shimMade(tmp.path(), "started"));
@@ -1794,6 +1834,7 @@ test "runStart: a caller that owns a screen gets a quiet start; one that does no
1794 .start_cmd = start_cmd, 1834 .start_cmd = start_cmd,
1795 .cache_path = null, 1835 .cache_path = null,
1796 .deadline_ms = 200, 1836 .deadline_ms = 200,
1837 .asked = true,
1797 .quiet = quiet, 1838 .quiet = quiet,
1798 } }, &carry, std.posix.STDIN_FILENO)); 1839 } }, &carry, std.posix.STDIN_FILENO));
1799 1840