a73x

eb9ab4e5

refactor: the asked recipe is the reading one plus `--start`

a73x   2026-08-29 14:38

Commit message
refactor: the asked recipe is the reading one plus `--start`

`start_argv` becomes `asked_argv`, and its remote word goes from `mux d
start` to `mux d endpoint --start`. The two argvs are now the same line
one flag apart, which is what lets the next commit delete the client's
whole start branch: the announce this run answers with IS the retry the
client used to spend a third ssh on.

Renamed rather than kept, because the field no longer names a command
that runs BESIDE the ssh line — it names the ssh line an asked dial runs
INSTEAD of the reading one.

Behaviour is unchanged here and deliberately still wasteful: the client
goes on reading the bare announce, reaping, running the asked argv and
asking again. The fold is the next commit's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

src/client/client.zig
Old New
@@ -191,10 +191,10 @@ pub const HandoffTarget = struct {
191 /// `ssh <host> mux d endpoint` as argv, prebuilt by mux_main — it has 191 /// `ssh <host> mux d endpoint` as argv, prebuilt by mux_main — it has
192 /// the allocator, and it builds this once for the whole session. 192 /// the allocator, and it builds this once for the whole session.
193 ssh_argv: []const []const u8, 193 ssh_argv: []const []const u8,
194 /// `ssh <host> mux d start`, from the same `handoff.recipeFor` call. 194 /// `ssh <host> mux d endpoint --start`, from the same
195 /// Empty is "nothing to start": the dial either finds a daemon or does 195 /// `handoff.recipeFor` call. Empty is "nothing to start": the dial
196 /// not. 196 /// either finds a daemon or does not.
197 start_argv: []const []const u8 = &.{}, 197 asked_argv: []const []const u8 = &.{},
198 /// Where the last announce is remembered. Null means never cache (an 198 /// Where the last announce is remembered. Null means never cache (an
199 /// uncacheable host, or no resolvable cache directory): every attach is 199 /// uncacheable host, or no resolvable cache directory): every attach is
200 /// then cold, which costs time and stays correct. 200 /// then cold, which costs time and stays correct.
@@ -213,7 +213,7 @@ pub const HandoffTarget = struct {
213 /// a live session's stderr into the alternate screen and corrupt the 213 /// a live session's stderr into the alternate screen and corrupt the
214 /// paint, to say what the [reconnecting] banner is already saying. 214 /// paint, to say what the [reconnecting] banner is already saying.
215 /// 215 ///
216 /// START a daemon that was not there, via `start_argv`. `mux d endpoint` 216 /// START a daemon that was not there, via `asked_argv`. `mux d endpoint`
217 /// no longer does it: a wall polls every listed host once a second, and 217 /// no longer does it: a wall polls every listed host once a second, and
218 /// a poll that starts daemons undoes a `mux d stop` a second after it is 218 /// a poll that starts daemons undoes a `mux d stop` a second after it is
219 /// typed. 219 /// typed.
@@ -245,7 +245,7 @@ pub const HandoffTarget = struct {
245 return .{ 245 return .{
246 .host = host, 246 .host = host,
247 .ssh_argv = r.ssh_argv, 247 .ssh_argv = r.ssh_argv,
248 .start_argv = r.start_argv, 248 .asked_argv = r.asked_argv,
249 .cache_path = r.cache_path, 249 .cache_path = r.cache_path,
250 .idle_ms = idle_ms, 250 .idle_ms = idle_ms,
251 .asked = asked, 251 .asked = asked,
@@ -564,7 +564,7 @@ pub const Transport = struct {
564 // `announceFailed` is that "ssh worked, the announce did not" 564 // `announceFailed` is that "ssh worked, the announce did not"
565 // shape: a start is worth trying only when the far end was 565 // shape: a start is worth trying only when the far end was
566 // reached and had nothing to say. 566 // reached and had nothing to say.
567 if (!h.asked or h.start_argv.len == 0 or !announceFailed(err)) return err; 567 if (!h.asked or h.asked_argv.len == 0 or !announceFailed(err)) return err;
568 // The remote's refusal, told apart from ssh's own by the code 568 // The remote's refusal, told apart from ssh's own by the code
569 // it exited with: `mux d endpoint` refuses an empty box with 1, 569 // it exited with: `mux d endpoint` refuses an empty box with 1,
570 // and ssh reports its OWN failures as 255 while passing a 570 // and ssh reports its OWN failures as 255 while passing a
@@ -580,7 +580,7 @@ pub const Transport = struct {
580 // A start that did not take is reported as the announce failure 580 // A start that did not take is reported as the announce failure
581 // it began as: `mux d start` has already said its own piece on 581 // it began as: `mux d start` has already said its own piece on
582 // stderr, and a second guess over the top of it would be worse. 582 // stderr, and a second guess over the top of it would be worse.
583 if (!runStart(alloc, h.start_argv, h.quiet)) return err; 583 if (!runStart(alloc, h.asked_argv, h.quiet)) return err;
584 child.* = try spawnPipe(alloc, h.ssh_argv); 584 child.* = try spawnPipe(alloc, h.ssh_argv);
585 // ONCE. A box that cannot hold a daemon costs this attach one 585 // ONCE. A box that cannot hold a daemon costs this attach one
586 // extra round trip; a loop would cost it the afternoon. 586 // extra round trip; a loop would cost it the afternoon.
@@ -1965,7 +1965,7 @@ test "openHandoff: a HandoffTarget nobody configured starts nothing" {
1965 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ 1965 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{
1966 .host = "fake", 1966 .host = "fake",
1967 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, 1967 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd },
1968 .start_argv = &.{ "/bin/sh", "-c", start_cmd }, 1968 .asked_argv = &.{ "/bin/sh", "-c", start_cmd },
1969 .cache_path = null, 1969 .cache_path = null,
1970 .deadline_ms = 200, 1970 .deadline_ms = 200,
1971 } }, &carry, std.posix.STDIN_FILENO)); 1971 } }, &carry, std.posix.STDIN_FILENO));
@@ -2001,7 +2001,7 @@ test "openHandoff: the dial a user ASKED for starts the daemon it did not find,
2001 var t = try Transport.open(alloc, .{ .hand = .{ 2001 var t = try Transport.open(alloc, .{ .hand = .{
2002 .host = "fake", 2002 .host = "fake",
2003 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, 2003 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd },
2004 .start_argv = &.{ "/bin/sh", "-c", start_cmd }, 2004 .asked_argv = &.{ "/bin/sh", "-c", start_cmd },
2005 .cache_path = null, 2005 .cache_path = null,
2006 .deadline_ms = 200, 2006 .deadline_ms = 200,
2007 .asked = true, 2007 .asked = true,
@@ -2033,7 +2033,7 @@ test "openHandoff: a dial nobody asked for reports the failure and starts nothin
2033 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ 2033 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{
2034 .host = "fake", 2034 .host = "fake",
2035 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, 2035 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd },
2036 .start_argv = &.{ "/bin/sh", "-c", start_cmd }, 2036 .asked_argv = &.{ "/bin/sh", "-c", start_cmd },
2037 .cache_path = null, 2037 .cache_path = null,
2038 .deadline_ms = 200, 2038 .deadline_ms = 200,
2039 .asked = false, 2039 .asked = false,
@@ -2069,7 +2069,7 @@ test "openHandoff: an ssh still alive after its stdout closed keeps its own exit
2069 var t = try Transport.open(alloc, .{ .hand = .{ 2069 var t = try Transport.open(alloc, .{ .hand = .{
2070 .host = "fake", 2070 .host = "fake",
2071 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, 2071 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd },
2072 .start_argv = &.{ "/bin/sh", "-c", start_cmd }, 2072 .asked_argv = &.{ "/bin/sh", "-c", start_cmd },
2073 .cache_path = null, 2073 .cache_path = null,
2074 .deadline_ms = 200, 2074 .deadline_ms = 200,
2075 .asked = true, 2075 .asked = true,
@@ -2101,7 +2101,7 @@ test "openHandoff: a start that does not help is tried once — a second announc
2101 const h: HandoffTarget = .{ 2101 const h: HandoffTarget = .{
2102 .host = "fake", 2102 .host = "fake",
2103 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, 2103 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd },
2104 .start_argv = &.{ "/bin/sh", "-c", start_cmd }, 2104 .asked_argv = &.{ "/bin/sh", "-c", start_cmd },
2105 .cache_path = null, 2105 .cache_path = null,
2106 .deadline_ms = 200, 2106 .deadline_ms = 200,
2107 .asked = true, 2107 .asked = true,
@@ -2155,7 +2155,7 @@ test "openHandoff: ssh's own failure is not a box without a daemon — no start,
2155 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ 2155 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{
2156 .host = "fake", 2156 .host = "fake",
2157 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, 2157 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd },
2158 .start_argv = &.{ "/bin/sh", "-c", start_cmd }, 2158 .asked_argv = &.{ "/bin/sh", "-c", start_cmd },
2159 .cache_path = null, 2159 .cache_path = null,
2160 .deadline_ms = 200, 2160 .deadline_ms = 200,
2161 .asked = true, 2161 .asked = true,
@@ -2201,7 +2201,7 @@ test "runStart: a caller that owns a screen gets a quiet start; one that does no
2201 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ 2201 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{
2202 .host = "fake", 2202 .host = "fake",
2203 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, 2203 .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd },
2204 .start_argv = &.{ "/bin/sh", "-c", start_cmd }, 2204 .asked_argv = &.{ "/bin/sh", "-c", start_cmd },
2205 .cache_path = null, 2205 .cache_path = null,
2206 .deadline_ms = 200, 2206 .deadline_ms = 200,
2207 .asked = true, 2207 .asked = true,
@@ -2769,7 +2769,7 @@ test "Target.fromSpec: asked is the caller's word, never a default" {
2769 try std.testing.expectEqual(asked, t.hand.asked); 2769 try std.testing.expectEqual(asked, t.hand.asked);
2770 try std.testing.expectEqual(@as(u32, 30_000), t.hand.idle_ms); 2770 try std.testing.expectEqual(@as(u32, 30_000), t.hand.idle_ms);
2771 // The start line is the recipe's, and it is what `asked` gates. 2771 // The start line is the recipe's, and it is what `asked` gates.
2772 try std.testing.expect(t.hand.start_argv.len > 0); 2772 try std.testing.expect(t.hand.asked_argv.len > 0);
2773 } 2773 }
2774 } 2774 }
2775 2775
src/client/handoff.zig
Old New
@@ -166,13 +166,15 @@ pub fn dialHost(host: []const u8) []const u8 {
166 } 166 }
167 167
168 /// What a bare-HOST target needs before it can be dialed: the coordination 168 /// What a bare-HOST target needs before it can be dialed: the coordination
169 /// command, the command that starts a daemon there, and the cache path. 169 /// command, the same command for a dial the user asked for, and the cache
170 /// path.
170 pub const Recipe = struct { 171 pub const Recipe = struct {
171 ssh_argv: []const []const u8, 172 ssh_argv: []const []const u8,
172 /// What a client runs when the ssh line found no daemon, and only on a 173 /// What a dial the user ASKED for runs INSTEAD of `ssh_argv`
173 /// dial the user ASKED for (`client.HandoffTarget.asked`): reading a 174 /// (`client.HandoffTarget.asked`): the same line with `--start`, so the
174 /// box must never start one. 175 /// remote ensures a daemon and announces in one run. Reading a box
175 start_argv: []const []const u8, 176 /// spells the bare verb and can therefore never start one.
177 asked_argv: []const []const u8,
176 /// null means attach UNCACHED — an uncacheable host (a separator in 178 /// null means attach UNCACHED — an uncacheable host (a separator in
177 /// the name) or no resolvable cache directory. Always cold, never 179 /// the name) or no resolvable cache directory. Always cold, never
178 /// wrong; the rule lives here rather than at each call site. 180 /// wrong; the rule lives here rather than at each call site.
@@ -185,7 +187,7 @@ pub const Recipe = struct {
185 // levels deep, so a future non-arena owner has two levels to free 187 // levels deep, so a future non-arena owner has two levels to free
186 // and a per-Tile free would be a double one. 188 // and a per-Tile free would be a double one.
187 freeArgv(alloc, self.ssh_argv); 189 freeArgv(alloc, self.ssh_argv);
188 freeArgv(alloc, self.start_argv); 190 freeArgv(alloc, self.asked_argv);
189 if (self.cache_path) |c| alloc.free(c); 191 if (self.cache_path) |c| alloc.free(c);
190 } 192 }
191 }; 193 };
@@ -216,8 +218,8 @@ fn sshArgv(
216 batch: bool, 218 batch: bool,
217 remote: []const u8, 219 remote: []const u8,
218 ) ![]const []const u8 { 220 ) ![]const []const u8 {
219 // Only the last word differs between the coordination command and the 221 // Only the remote word differs between the reading command and the
220 // start command, and a drift between the two spellings would start a 222 // asking one, and a drift between the two spellings would start a
221 // daemon somewhere other than where the attach then looks for it. 223 // daemon somewhere other than where the attach then looks for it.
222 // 224 //
223 // BatchMode is for the recipes NOBODY is sitting in front of — the 225 // BatchMode is for the recipes NOBODY is sitting in front of — the
@@ -267,11 +269,11 @@ fn sshArgv(
267 pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8, batch: bool) !Recipe { 269 pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8, batch: bool) !Recipe {
268 const cmd = try sshArgv(alloc, host, batch, "mux d endpoint"); 270 const cmd = try sshArgv(alloc, host, batch, "mux d endpoint");
269 errdefer freeArgv(alloc, cmd); 271 errdefer freeArgv(alloc, cmd);
270 const start = try sshArgv(alloc, host, batch, "mux d start"); 272 const asked = try sshArgv(alloc, host, batch, "mux d endpoint --start");
271 errdefer freeArgv(alloc, start); 273 errdefer freeArgv(alloc, asked);
272 return .{ 274 return .{
273 .ssh_argv = cmd, 275 .ssh_argv = cmd,
274 .start_argv = start, 276 .asked_argv = asked,
275 .cache_path = xdg.hostCachePath(alloc, host) catch null, 277 .cache_path = xdg.hostCachePath(alloc, host) catch null,
276 }; 278 };
277 } 279 }
@@ -463,18 +465,21 @@ test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-l
463 }, r.ssh_argv); 465 }, r.ssh_argv);
464 } 466 }
465 467
466 test "recipeFor: the start command is the ssh line with `mux d start` — only a dial the user asked for may start a daemon" { 468 test "recipeFor: the asked command is the ssh line with `mux d endpoint --start` — one run that starts what is missing and announces" {
467 const alloc = std.testing.allocator; 469 const alloc = std.testing.allocator;
468 const asking = try recipeFor(alloc, "user@box", false); 470 const asking = try recipeFor(alloc, "user@box", false);
469 defer asking.deinit(alloc); 471 defer asking.deinit(alloc);
472 // The SAME verb as `ssh_argv`, one flag apart, and that is the whole
473 // of the design: the announce this run answers with is the retry the
474 // client used to spend a third ssh on.
470 try expectArgv(&.{ 475 try expectArgv(&.{
471 "ssh", 476 "ssh",
472 "user@box", 477 "user@box",
473 "PATH=\"$PATH:$HOME/.local/bin\" mux d start", 478 "PATH=\"$PATH:$HOME/.local/bin\" mux d endpoint --start",
474 }, asking.start_argv); 479 }, asking.asked_argv);
475 // The batch flag travels with the recipe, so the start line carries it 480 // The batch flag travels with the recipe, so the asked line carries it
476 // too: a poller's recipe never starts anything, but `mux hosts` and the 481 // too: a poller's recipe never asks, but `mux hosts` and the wall build
477 // wall build both argvs from the same call. 482 // both argvs from the same call.
478 const quiet = try recipeFor(alloc, "gate", true); 483 const quiet = try recipeFor(alloc, "gate", true);
479 defer quiet.deinit(alloc); 484 defer quiet.deinit(alloc);
480 // `-o` and its value are two words, the way ssh's own getopt reads 485 // `-o` and its value are two words, the way ssh's own getopt reads
@@ -486,8 +491,8 @@ test "recipeFor: the start command is the ssh line with `mux d start` — only a
486 "-o", 491 "-o",
487 "ConnectTimeout=5", 492 "ConnectTimeout=5",
488 "gate", 493 "gate",
489 "PATH=\"$PATH:$HOME/.local/bin\" mux d start", 494 "PATH=\"$PATH:$HOME/.local/bin\" mux d endpoint --start",
490 }, quiet.start_argv); 495 }, quiet.asked_argv);
491 } 496 }
492 497
493 test "recipeFor: a batch recipe cannot prompt, an interactive one still can" { 498 test "recipeFor: a batch recipe cannot prompt, an interactive one still can" {