a73x

1bacdb57

refactor: one owner for the probe-or-spawn wrapper

a73x   2026-08-29 10:01

Commit message
refactor: one owner for the probe-or-spawn wrapper

`mux d start` and the local attach's auto-start wrapped `ensureDaemon`
the same way twice: resolve this image, build stderr Progress under the
verb the user typed, wait `start_deadline_ms`, and turn the two failures
into a narrated non-answer. `spawn.ensure` is that wrapper now; `startCmd`
and `ensureForAttach` differ only in the argv they forward and their
LogSpec, which is the difference that was always meant to be visible.

The invariant is untouched: `ensure` resolves the exe through
`spawn.selfExe` and nothing else names one, so a local start still execs
THIS image. `truncate` stays a per-caller answer — start truncates the
log, an attach appends — pinned by "ensureDaemon: an attach appends to
the log, `muxd start` truncates it", and e2e_03_side still reads
/proc/PID/exe and /proc/PID/comm off a spawned daemon.

`mux_main.ensureLocalDaemon` was a three-line pass-through and its two
callers now say `spawn.ensureForAttach(alloc, sock, "mux")`; spawn.zig's
header already owns the "this is the only auto-start" claim.

Net −20 product lines.

src/cli/main.zig
Old New
@@ -1165,28 +1165,11 @@ fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 {
1165 /// bind address, a missing key file) surfaces in the daemon's log, which 1165 /// bind address, a missing key file) surfaces in the daemon's log, which
1166 /// the failure path names. 1166 /// the failure path names.
1167 fn startCmd(alloc: std.mem.Allocator, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 { 1167 fn startCmd(alloc: std.mem.Allocator, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 {
1168 const progress: spawn.Progress = .{
1169 .fd = std.posix.STDERR_FILENO,
1170 .prefix = "mux d",
1171 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
1172 };
1173 // Default log path: the xdg one is the whole point for a real daemon. 1168 // Default log path: the xdg one is the whole point for a real daemon.
1174 // Truncating, and this is the only caller that does: `start` is the one 1169 // Truncating, and this is the only caller that does: `start` is the one
1175 // verb whose user asked for a (re)start, so the log they go on to read 1170 // verb whose user asked for a (re)start, so the log they go on to read
1176 // must be about the daemon they just started. 1171 // must be about the daemon they just started.
1177 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 1172 const r = spawn.ensure(alloc, forwarded, sock_path, "mux d", .{ .truncate = true }) orelse return 1;
1178 const exe = spawn.selfExe(&exe_buf);
1179 const r = spawn.ensureDaemon(alloc, exe, forwarded, sock_path, progress, spawn.start_deadline_ms, .{
1180 .truncate = true,
1181 }) catch |err| switch (err) {
1182 // The failure line, with the log path, was already printed by
1183 // Progress — a second line here would say the same thing worse.
1184 error.NeverAnswered => return 1,
1185 error.SpawnFailed => {
1186 std.debug.print("mux d: could not spawn {s}: {s}\n", .{ exe, @errorName(err) });
1187 return 1;
1188 },
1189 };
1190 if (r == .already_running) { 1173 if (r == .already_running) {
1191 std.debug.print( 1174 std.debug.print(
1192 "mux d: already running on {s} (stop it first with `mux d stop --sock {s}` if you meant different flags)\n", 1175 "mux d: already running on {s} (stop it first with `mux d stop --sock {s}` if you meant different flags)\n",
src/cli/mux_main.zig
Old New
@@ -365,12 +365,6 @@ pub fn main(args: []const [:0]const u8) !u8 {
365 } 365 }
366 } 366 }
367 367
368 /// The ONLY auto-start left, and it starts this image by its own /proc
369 /// link — no name to look up, so none to get wrong.
370 fn ensureLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !bool {
371 return spawn.ensureForAttach(alloc, sock_path, "mux");
372 }
373
374 /// Whether the wall has to start the local daemon itself. Asked of the OS, 368 /// Whether the wall has to start the local daemon itself. Asked of the OS,
375 /// not of the file: the daemon dies on every reboot while its line lives on. 369 /// not of the file: the daemon dies on every reboot while its line lives on.
376 fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool { 370 fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool {
@@ -412,7 +406,7 @@ fn attachLocal(
412 // was doomed at parse. 406 // was doomed at parse.
413 if (sockpath.tooLong("mux", sock_path)) return 1; 407 if (sockpath.tooLong("mux", sock_path)) return 1;
414 408
415 if (!try ensureLocalDaemon(alloc, sock_path)) return 1; 409 if (!try spawn.ensureForAttach(alloc, sock_path, "mux")) return 1;
416 return wallview.runAttach( 410 return wallview.runAttach(
417 alloc, 411 alloc,
418 .{ .sock = sock_path }, 412 .{ .sock = sock_path },
@@ -471,7 +465,7 @@ fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
471 // Failure is not a refusal: the wall still opens and that host's 465 // Failure is not a refusal: the wall still opens and that host's
472 // poller keeps redialling, so a daemon started elsewhere shows up. 466 // poller keeps redialling, so a daemon started elsewhere shows up.
473 if (sockpath.defaultSockPath(arena) catch null) |sock| { 467 if (sockpath.defaultSockPath(arena) catch null) |sock| {
474 if (localNeedsStart(&h, sock)) _ = try ensureLocalDaemon(alloc, sock); 468 if (localNeedsStart(&h, sock)) _ = try spawn.ensureForAttach(alloc, sock, "mux");
475 } 469 }
476 470
477 const key = std.posix.getenv(xdg.key_env); 471 const key = std.posix.getenv(xdg.key_env);
src/cli/spawn.zig
Old New
@@ -262,47 +262,49 @@ pub fn probe(sock_path: []const u8) bool {
262 return true; 262 return true;
263 } 263 }
264 264
265 /// The local entry's auto-start alone, naming no binary: there is one, 265 /// Both starters, one wrapper: this image, the one deadline, a failure
266 /// and it is this. 266 /// already narrated — null needs only an exit code.
267 pub fn ensureForAttach( 267 pub fn ensure(
268 alloc: std.mem.Allocator, 268 alloc: std.mem.Allocator,
269 run_args: []const [:0]const u8,
269 sock_path: []const u8, 270 sock_path: []const u8,
270 prefix: []const u8, 271 prefix: []const u8,
271 ) error{OutOfMemory}!bool { 272 log_spec: LogSpec,
272 const sock_z = try alloc.dupeZ(u8, sock_path); 273 ) ?Ensured {
273 defer alloc.free(sock_z);
274 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 274 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
275 const exe = selfExe(&exe_buf); 275 const exe = selfExe(&exe_buf);
276 // Bare, and the reason is the whole of why this is not `start`: a QUIC
277 // listener must be asked for, never appear because someone attached.
278 const run_args = [_][:0]const u8{ "--sock", sock_z };
279 const progress: Progress = .{ 276 const progress: Progress = .{
280 .fd = std.posix.STDERR_FILENO, 277 .fd = std.posix.STDERR_FILENO,
281 .prefix = prefix, 278 .prefix = prefix,
282 .tty = std.posix.isatty(std.posix.STDERR_FILENO), 279 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
283 }; 280 };
284 _ = ensureDaemon( 281 return ensureDaemon(alloc, exe, run_args, sock_path, progress, start_deadline_ms, log_spec) catch |err| {
285 alloc, 282 // NeverAnswered's failure line, with the log path, was already
286 exe, 283 // printed by Progress — a second line would say the same thing
287 &run_args, 284 // worse. A spawn that never happened has nothing printed yet, and
288 sock_path, 285 // std.debug.print rather than progress.emitFmt because an exe path
289 progress, 286 // can run to max_path_bytes and emitFmt's fixed buffer would drop
290 start_deadline_ms, 287 // the whole line rather than shorten it. The RESOLVED path, so the
291 .{ .truncate = false }, 288 // line names a file an operator can stat.
292 ) catch |err| switch (err) { 289 if (err == error.SpawnFailed)
293 // The failure line, with the log path, was already printed by
294 // Progress — a second line would say the same thing worse.
295 error.NeverAnswered => return false,
296 error.SpawnFailed => {
297 // std.debug.print rather than progress.emitFmt: an exe path can
298 // run to max_path_bytes, and emitFmt's fixed buffer would drop
299 // the whole line rather than shorten it. The RESOLVED path, so
300 // the line names a file an operator can stat.
301 std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, exe, @errorName(err) }); 290 std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, exe, @errorName(err) });
302 return false; 291 return null;
303 },
304 }; 292 };
305 return true; 293 }
294
295 /// The local entry's auto-start alone, naming no binary: there is one,
296 /// and it is this.
297 pub fn ensureForAttach(
298 alloc: std.mem.Allocator,
299 sock_path: []const u8,
300 prefix: []const u8,
301 ) error{OutOfMemory}!bool {
302 const sock_z = try alloc.dupeZ(u8, sock_path);
303 defer alloc.free(sock_z);
304 // Bare, and the reason is the whole of why this is not `start`: a QUIC
305 // listener must be asked for, never appear because someone attached.
306 const run_args = [_][:0]const u8{ "--sock", sock_z };
307 return ensure(alloc, &run_args, sock_path, prefix, .{ .truncate = false }) != null;
306 } 308 }
307 309
308 // --------------------------------------------------------------------------- 310 // ---------------------------------------------------------------------------