2c2dbcec
fix: HandoffTarget.asked defaults to false — a forgotten field must not start a daemon
a73x 2026-08-28 19:53
Commit message
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 | ||