eb9ab4e5
refactor: the asked recipe is the reading one plus `--start`
a73x 2026-08-29 14:38
Commit message
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" { |