a73x

7ae9b7db

refactor: a local start execs THIS image; nothing walks PATH for a sibling

a73x   2026-08-28 22:34

Commit message
refactor: a local start execs THIS image; nothing walks PATH for a sibling

`spawn.ensureDaemon` was handed a path found by `findInPath("muxd")`. There
is one binary now, so the path it is handed is `spawn.self_exe` —
`/proc/self/exe`, the running image by its own kernel link — and `findInPath`
goes. The ambient-PATH trap closes by construction: there is no name left in
the tree to resolve, so none to resolve wrong. An `execvp` graded whatever
release was installed, and once did, a v0.0.1-10 with no agent code in it.

Running the daemon in the fork instead of exec'ing was tried and reverted:
`std.debug.MemoryAccessor` caches the pid it reads memory through, so the
child's first DebugAllocator stack trace calls process_vm_readv on the PARENT
and hits `unreachable // own pid is always valid`. Deterministic, in every
Debug build, minutes after `up (0.1s)` had been printed. decisions.md carries
the core trace and the rule it leaves behind.

The unit test asks the child what it BECAME: `$0` is the file the kernel
exec'd, `$*` the argv it was handed. Nothing on this box resolves by name to
a file in a fresh tmp dir, so a spawn that searched PATH cannot pass it.

Remote start is still ssh, respelled: `mux d endpoint`, `mux d start`.

src/client/handoff.zig
Old New
@@ -1,5 +1,5 @@
1 //! The ssh→QUIC handoff's shared vocabulary: the announce line `muxd 1 //! The ssh→QUIC handoff's shared vocabulary: the announce line
2 //! endpoint` prints and `mux` parses, the per-host cache file that 2 //! `mux d endpoint` prints and `mux` parses, the per-host cache file that
3 //! remembers it, and the strip that turns an ssh destination into a 3 //! remembers it, and the strip that turns an ssh destination into a
4 //! dialable host. Pure by design — no sockets, no processes — so the 4 //! dialable host. Pure by design — no sockets, no processes — so the
5 //! whole surface tests without a daemon. 5 //! whole surface tests without a daemon.
@@ -47,7 +47,7 @@ pub const deadline_ms: u32 = 2000;
47 /// spellings does not compile. 47 /// spellings does not compile.
48 pub const key_len = 32; 48 pub const key_len = 32;
49 49
50 /// What a `muxd endpoint` announce carries. 50 /// What a `mux d endpoint` announce carries.
51 pub const Endpoint = struct { 51 pub const Endpoint = struct {
52 port: u16, 52 port: u16,
53 key: [key_len]u8, 53 key: [key_len]u8,
@@ -89,7 +89,7 @@ pub const CacheError = error{
89 89
90 /// `endpoint <port> <64 lowercase hex chars>\n` into `buf`. 90 /// `endpoint <port> <64 lowercase hex chars>\n` into `buf`.
91 /// 91 ///
92 /// One writer — `muxd endpoint` — and two readers: the client reading the 92 /// One writer — `mux d endpoint` — and two readers: the client reading the
93 /// ssh pipe, and the client reading its own cache file, which stores this 93 /// ssh pipe, and the client reading its own cache file, which stores this
94 /// exact line. One grammar, not two, so a cache written by one version and 94 /// exact line. One grammar, not two, so a cache written by one version and
95 /// read by another can only agree or fail loudly. 95 /// read by another can only agree or fail loudly.
@@ -213,9 +213,9 @@ fn sshLine(
213 // The PATH suffix, single-quoted so the REMOTE shell expands it: sshd 213 // The PATH suffix, single-quoted so the REMOTE shell expands it: sshd
214 // runs this through a non-login, non-interactive shell that never 214 // runs this through a non-login, non-interactive shell that never
215 // sources the profile putting ~/.local/bin (make install's target) on 215 // sources the profile putting ~/.local/bin (make install's target) on
216 // PATH — without it a muxd the user can run by hand is invisible here. 216 // PATH — without it a `mux` the user can run by hand is invisible here.
217 // APPENDED, deliberately: a fallback place to look, never a shadow over 217 // APPENDED, deliberately: a fallback place to look, never a shadow over
218 // whatever muxd the remote PATH already resolves (or, under the e2e ssh 218 // whatever `mux` the remote PATH already resolves (or, under the e2e ssh
219 // shim, over the binary under test). 219 // shim, over the binary under test).
220 return std.fmt.allocPrint( 220 return std.fmt.allocPrint(
221 alloc, 221 alloc,
@@ -224,15 +224,15 @@ fn sshLine(
224 ); 224 );
225 } 225 }
226 226
227 /// ONE owner for the handoff recipe: `mux HOST` and a `muxweb` HOST tile 227 /// ONE owner for the handoff recipe: `mux HOST` and a `mux web` HOST tile
228 /// build the identical thing, and a drift between two spellings of the 228 /// build the identical thing, and a drift between two spellings of the
229 /// ssh line would quietly point the two binaries at different remote 229 /// ssh line would quietly point the two binaries at different remote
230 /// commands. Building it here (rather than in client.zig) is what keeps 230 /// commands. Building it here (rather than in client.zig) is what keeps
231 /// the client free of XDG and of allocating a command line. 231 /// the client free of XDG and of allocating a command line.
232 pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8, batch: bool) !Recipe { 232 pub fn recipeFor(alloc: std.mem.Allocator, host: []const u8, batch: bool) !Recipe {
233 const cmd = try sshLine(alloc, host, batch, "muxd endpoint"); 233 const cmd = try sshLine(alloc, host, batch, "mux d endpoint");
234 errdefer alloc.free(cmd); 234 errdefer alloc.free(cmd);
235 const start = try sshLine(alloc, host, batch, "muxd start"); 235 const start = try sshLine(alloc, host, batch, "mux d start");
236 errdefer alloc.free(start); 236 errdefer alloc.free(start);
237 return .{ 237 return .{
238 .ssh_cmd = cmd, 238 .ssh_cmd = cmd,
@@ -436,21 +436,21 @@ test "announce: every shape of junk is a named error" {
436 test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-login shell never sources the profile that would" { 436 test "recipeFor: the remote command carries ~/.local/bin itself — sshd's non-login shell never sources the profile that would" {
437 const r = try recipeFor(std.testing.allocator, "user@box", false); 437 const r = try recipeFor(std.testing.allocator, "user@box", false);
438 defer r.deinit(std.testing.allocator); 438 defer r.deinit(std.testing.allocator);
439 // APPENDED, not prepended: this adds a place to look when muxd is 439 // APPENDED, not prepended: this adds a place to look when `mux` is
440 // nowhere on the remote PATH; it must never let a stale ~/.local/bin 440 // nowhere on the remote PATH; it must never let a stale ~/.local/bin
441 // shadow a muxd the PATH already resolves. 441 // shadow a `mux` the PATH already resolves.
442 try std.testing.expectEqualStrings( 442 try std.testing.expectEqualStrings(
443 "ssh user@box 'PATH=\"$PATH:$HOME/.local/bin\" muxd endpoint'", 443 "ssh user@box 'PATH=\"$PATH:$HOME/.local/bin\" mux d endpoint'",
444 r.ssh_cmd, 444 r.ssh_cmd,
445 ); 445 );
446 } 446 }
447 447
448 test "recipeFor: the start command is the ssh line with `muxd start` — only a dial the user asked for may start a daemon" { 448 test "recipeFor: the start command is the ssh line with `mux d start` — only a dial the user asked for may start a daemon" {
449 const alloc = std.testing.allocator; 449 const alloc = std.testing.allocator;
450 const asking = try recipeFor(alloc, "user@box", false); 450 const asking = try recipeFor(alloc, "user@box", false);
451 defer asking.deinit(alloc); 451 defer asking.deinit(alloc);
452 try std.testing.expectEqualStrings( 452 try std.testing.expectEqualStrings(
453 "ssh user@box 'PATH=\"$PATH:$HOME/.local/bin\" muxd start'", 453 "ssh user@box 'PATH=\"$PATH:$HOME/.local/bin\" mux d start'",
454 asking.start_cmd, 454 asking.start_cmd,
455 ); 455 );
456 // The batch flag travels with the recipe, so the start line carries it 456 // The batch flag travels with the recipe, so the start line carries it
@@ -459,7 +459,7 @@ test "recipeFor: the start command is the ssh line with `muxd start` — only a
459 const quiet = try recipeFor(alloc, "gate", true); 459 const quiet = try recipeFor(alloc, "gate", true);
460 defer quiet.deinit(alloc); 460 defer quiet.deinit(alloc);
461 try std.testing.expectEqualStrings( 461 try std.testing.expectEqualStrings(
462 "ssh -o BatchMode=yes -o ConnectTimeout=5 gate 'PATH=\"$PATH:$HOME/.local/bin\" muxd start'", 462 "ssh -o BatchMode=yes -o ConnectTimeout=5 gate 'PATH=\"$PATH:$HOME/.local/bin\" mux d start'",
463 quiet.start_cmd, 463 quiet.start_cmd,
464 ); 464 );
465 } 465 }
src/client/spawn.zig
Old New
@@ -1,12 +1,30 @@
1 //! Get a daemon onto a socket path: probe, spawn detached, poll until it 1 //! Get a daemon onto a socket path: probe, spawn detached, poll until it
2 //! answers. `muxd start` is explicit and spelled out; the LOCAL client's 2 //! answers. `mux d start` is explicit and spelled out; the LOCAL client's
3 //! own entry (`mux` with no host) is the one attach that may still start 3 //! own entry (`mux` with no host) is the one attach that may still start
4 //! one, through `ensureForAttach` below. The remote verbs a client reaches 4 //! one, through `ensureForAttach` below. The remote verbs a client reaches
5 //! over ssh do not: reading a box must never create a session there. 5 //! over ssh do not: reading a box must never create a session there.
6 //!
7 //! What it spawns is `self_exe` — this image, by its own /proc link — and
8 //! never a name resolved against PATH. That is the end of the ambient-PATH
9 //! trap: an `execvp("muxd")` grades whatever release is installed, and did
10 //! (an e2e leg whose daemon had died ran a v0.0.1-10 with no agent code in
11 //! it and passed).
6 const std = @import("std"); 12 const std = @import("std");
7 const xdg = @import("xdg"); 13 const xdg = @import("xdg");
8 14
9 pub const EnsureError = error{ BinaryNotFound, SpawnFailed, NeverAnswered }; 15 pub const EnsureError = error{ BinaryNotFound, SpawnFailed, NeverAnswered };
16
17 /// The binary every production start execs: the running image, named by the
18 /// kernel rather than looked up. It survives a rename or a delete under the
19 /// running daemon, because the link is to the inode and not to the path.
20 ///
21 /// The child execs rather than simply running the daemon in the fork, and
22 /// that is not a preference: `std.debug.MemoryAccessor` caches the pid it
23 /// reads memory through, so a forked child's first DebugAllocator stack
24 /// trace calls `process_vm_readv` on the parent, gets ESRCH, and panics on
25 /// `unreachable // own pid is always valid`. Measured, deterministic, and
26 /// invisible until the daemon has been up long enough to allocate.
27 pub const self_exe = "/proc/self/exe";
10 pub const Ensured = enum { already_running, started }; 28 pub const Ensured = enum { already_running, started };
11 29
12 /// How long a spawn gets to answer, for every caller. One number because a 30 /// How long a spawn gets to answer, for every caller. One number because a
@@ -109,15 +127,19 @@ pub fn ensureDaemon(
109 const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed; 127 const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed;
110 defer devnull.close(); 128 defer devnull.close();
111 129
112 // argv for the child: exe run <forwarded...>, all null-terminated. 130 // argv for the child: mux d run <forwarded...>, all null-terminated.
131 // The mode word is spelled out so `ps` shows a daemon as a daemon —
132 // it is the only thing separating the long-lived process from the
133 // `mux d start` that spawned it.
113 const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed; 134 const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed;
114 defer alloc.free(exe_z); 135 defer alloc.free(exe_z);
115 const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 2, null) catch 136 const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 3, null) catch
116 return error.SpawnFailed; 137 return error.SpawnFailed;
117 defer alloc.free(argv); 138 defer alloc.free(argv);
118 argv[0] = exe_z.ptr; 139 argv[0] = "mux";
119 argv[1] = "run"; 140 argv[1] = "d";
120 for (run_args, 0..) |a, i| argv[i + 2] = a.ptr; 141 argv[2] = "run";
142 for (run_args, 0..) |a, i| argv[i + 3] = a.ptr;
121 143
122 progress.emitFmt("{s}: starting\u{2026}", .{progress.prefix}); 144 progress.emitFmt("{s}: starting\u{2026}", .{progress.prefix});
123 if (!progress.tty) progress.emit("\n"); 145 if (!progress.tty) progress.emit("\n");
@@ -213,11 +235,10 @@ pub fn probe(sock_path: []const u8) bool {
213 return true; 235 return true;
214 } 236 }
215 237
216 /// The local entry's auto-start alone; `muxd start` owes a verdict and is 238 /// The local entry's auto-start alone, naming no binary: there is one,
217 /// deliberately not this. 239 /// and it is this.
218 pub fn ensureForAttach( 240 pub fn ensureForAttach(
219 alloc: std.mem.Allocator, 241 alloc: std.mem.Allocator,
220 exe: []const u8,
221 sock_path: []const u8, 242 sock_path: []const u8,
222 prefix: []const u8, 243 prefix: []const u8,
223 ) error{OutOfMemory}!bool { 244 ) error{OutOfMemory}!bool {
@@ -233,7 +254,7 @@ pub fn ensureForAttach(
233 }; 254 };
234 _ = ensureDaemon( 255 _ = ensureDaemon(
235 alloc, 256 alloc,
236 exe, 257 self_exe,
237 &run_args, 258 &run_args,
238 sock_path, 259 sock_path,
239 progress, 260 progress,
@@ -247,40 +268,13 @@ pub fn ensureForAttach(
247 // std.debug.print rather than progress.emitFmt: an exe path can 268 // std.debug.print rather than progress.emitFmt: an exe path can
248 // run to max_path_bytes, and emitFmt's fixed buffer would drop 269 // run to max_path_bytes, and emitFmt's fixed buffer would drop
249 // the whole line rather than shorten it. 270 // the whole line rather than shorten it.
250 std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, exe, @errorName(err) }); 271 std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, self_exe, @errorName(err) });
251 return false; 272 return false;
252 }, 273 },
253 }; 274 };
254 return true; 275 return true;
255 } 276 }
256 277
257 /// An empty PATH segment is skipped, not read as cwd: never exec a stray
258 /// `./muxd`.
259 pub fn findInPath(
260 alloc: std.mem.Allocator,
261 path_env: []const u8,
262 name: []const u8,
263 ) error{OutOfMemory}!?[]const u8 {
264 var it = std.mem.splitScalar(u8, path_env, ':');
265 while (it.next()) |dir| {
266 // POSIX reads an empty segment as the current directory. Only the
267 // IMPLICIT cwd is refused: an explicit `.` still resolves against
268 // it, as execvp would. A typed entry is a choice someone made; a
269 // stray colon is invisible, and only the invisible one is a trap.
270 if (dir.len == 0) continue;
271 const candidate = try std.fs.path.join(alloc, &.{ dir, name });
272 // access(X_OK) also succeeds on a searchable DIRECTORY named
273 // `name`, so a hit is not proof of an executable file. The exec
274 // that follows is what finally rejects it.
275 std.posix.access(candidate, std.posix.X_OK) catch {
276 alloc.free(candidate);
277 continue;
278 };
279 return candidate;
280 }
281 return null;
282 }
283
284 // --------------------------------------------------------------------------- 278 // ---------------------------------------------------------------------------
285 279
286 const testtmp = @import("testtmp"); 280 const testtmp = @import("testtmp");
@@ -430,6 +424,64 @@ test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive"
430 _ = std.posix.waitpid(last_spawned_pid, 0); 424 _ = std.posix.waitpid(last_spawned_pid, 0);
431 } 425 }
432 426
427 test "ensureDaemon: the child execs the path it was HANDED, never a name off PATH" {
428 var tmp = try testtmp.TmpDir.make();
429 defer tmp.cleanup();
430 var pbuf: [160]u8 = undefined;
431 var sbuf: [160]u8 = undefined;
432 var lbuf: [160]u8 = undefined;
433 var rbuf: [160]u8 = undefined;
434 const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
435 const sock = try std.fmt.bufPrint(&sbuf, "{s}/self.sock", .{tmp.path()});
436 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/mux.log", .{tmp.path()});
437 const seen = try std.fmt.bufPrint(&rbuf, "{s}/child.exe", .{tmp.path()});
438
439 // `$0` is the kernel's answer to "which file did you exec": for a
440 // shebang script it is the script, whatever argv[0] the caller wrote.
441 // Nothing on this box resolves by NAME to a file in a fresh tmp dir, so
442 // a spawn that searched PATH cannot pass this. `$*` pins the other half
443 // — the daemon is asked for `d run`, which is also what makes a daemon
444 // legible in `ps`. Production hands `self_exe` and nothing else, so the
445 // same mechanism cannot reach a sibling: `mux` used to hunt PATH for a
446 // `muxd`, and an e2e leg graded an installed v0.0.1-10 that way.
447 var script: [512]u8 = undefined;
448 try tmp.dir.writeFile(.{
449 .sub_path = "stub.sh",
450 .data = try std.fmt.bufPrint(&script,
451 \\#!/bin/sh
452 \\printf '%s|%s' "$0" "$*" > "{s}"
453 \\exec sleep 30
454 \\
455 , .{seen}),
456 });
457 const f = try tmp.dir.openFile("stub.sh", .{});
458 try f.chmod(0o755);
459 f.close();
460
461 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
462 std.testing.allocator,
463 stub,
464 &.{},
465 sock,
466 silentProgress(),
467 400,
468 .{ .path = log, .truncate = true },
469 ));
470 defer {
471 std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
472 _ = std.posix.waitpid(last_spawned_pid, 0);
473 }
474
475 var got_buf: [std.fs.max_path_bytes]u8 = undefined;
476 // Read through the failure rather than around it: a child that never
477 // ran the stub at all must fail as a MISMATCH naming what it became,
478 // not as a FileNotFound three frames inside std.
479 const got = std.fs.cwd().readFile(seen, &got_buf) catch "<the child ran something else>";
480 var want_buf: [200]u8 = undefined;
481 const want = try std.fmt.bufPrint(&want_buf, "{s}|d run", .{stub});
482 try std.testing.expectEqualStrings(want, std.mem.trimRight(u8, got, "\n"));
483 }
484
433 test "ensureDaemon: an attach appends to the log, `muxd start` truncates it" { 485 test "ensureDaemon: an attach appends to the log, `muxd start` truncates it" {
434 var tmp = try testtmp.TmpDir.make(); 486 var tmp = try testtmp.TmpDir.make();
435 defer tmp.cleanup(); 487 defer tmp.cleanup();
@@ -492,107 +544,6 @@ test "ensureDaemon: an attach appends to the log, `muxd start` truncates it" {
492 ); 544 );
493 } 545 }
494 546
495 test "findInPath: first executable hit wins; non-executables are skipped" {
496 const alloc = std.testing.allocator;
497 var tmp = try testtmp.TmpDir.make();
498 defer tmp.cleanup();
499
500 var abuf: [280]u8 = undefined;
501 var bbuf: [280]u8 = undefined;
502 var cbuf: [280]u8 = undefined;
503 const dir_a = try std.fmt.bufPrint(&abuf, "{s}/a", .{tmp.path()});
504 const dir_b = try std.fmt.bufPrint(&bbuf, "{s}/b", .{tmp.path()});
505 const dir_c = try std.fmt.bufPrint(&cbuf, "{s}/c", .{tmp.path()});
506 try std.fs.cwd().makePath(dir_a);
507 try std.fs.cwd().makePath(dir_b);
508 try std.fs.cwd().makePath(dir_c);
509
510 // a/muxd exists but is NOT executable; b/muxd is. The search must skip
511 // the first and return the second — access(X_OK) is the filter, not
512 // mere existence.
513 var pbuf: [560]u8 = undefined;
514 const not_exec = try std.fmt.bufPrint(&pbuf, "{s}/muxd", .{dir_a});
515 (try std.fs.cwd().createFile(not_exec, .{ .mode = 0o600 })).close();
516 var pbuf2: [560]u8 = undefined;
517 const exec = try std.fmt.bufPrint(&pbuf2, "{s}/muxd", .{dir_b});
518 (try std.fs.cwd().createFile(exec, .{ .mode = 0o700 })).close();
519
520 // c/muxd is executable too and sits AFTER b. Without it "first hit
521 // wins" is unpinned: with one executable in PATH, a search that kept
522 // the last match returns the same b/muxd a correct one does.
523 var pbuf3: [560]u8 = undefined;
524 const later = try std.fmt.bufPrint(&pbuf3, "{s}/muxd", .{dir_c});
525 (try std.fs.cwd().createFile(later, .{ .mode = 0o700 })).close();
526
527 var envbuf: [1200]u8 = undefined;
528 const path_env = try std.fmt.bufPrint(&envbuf, "{s}:{s}:{s}", .{ dir_a, dir_b, dir_c });
529
530 const found = (try findInPath(alloc, path_env, "muxd")).?;
531 defer alloc.free(found);
532 try std.testing.expectEqualStrings(exec, found);
533 }
534
535 test "findInPath: nothing executable anywhere is null, not an error" {
536 const alloc = std.testing.allocator;
537 var tmp = try testtmp.TmpDir.make();
538 defer tmp.cleanup();
539 // A non-executable muxd, so the directory is searched and comes up
540 // empty rather than being empty — which is what the name claims. An
541 // absent file and a present-but-unrunnable one are different misses;
542 // this pins the one an operator actually hits (a downloaded binary
543 // nobody chmod'd).
544 var pbuf: [560]u8 = undefined;
545 const not_exec = try std.fmt.bufPrint(&pbuf, "{s}/muxd", .{tmp.path()});
546 (try std.fs.cwd().createFile(not_exec, .{ .mode = 0o600 })).close();
547 try std.testing.expectEqual(
548 @as(?[]const u8, null),
549 try findInPath(alloc, tmp.path(), "muxd"),
550 );
551 }
552
553 test "findInPath: empty PATH segments are skipped, never read as cwd" {
554 const alloc = std.testing.allocator;
555 // POSIX reads an empty segment as the current directory. An attach
556 // must never execute a ./muxd it happens to be standing next to, so
557 // the helper skips them — an all-empty PATH finds nothing even when
558 // the cwd contains an executable by that name.
559 //
560 // Which is why the test STANDS in such a directory. The cwd is
561 // process-global and moving it is unpleasant, but from anywhere else
562 // this case cannot fail: with no ./muxd underfoot, a helper that
563 // dutifully searched the cwd would find nothing there and return the
564 // same null a correct one does. The bait is what makes the assertion
565 // an assertion.
566 var tmp = try testtmp.TmpDir.make();
567 defer tmp.cleanup();
568 var pbuf: [560]u8 = undefined;
569 const bait = try std.fmt.bufPrint(&pbuf, "{s}/muxd", .{tmp.path()});
570 (try std.fs.cwd().createFile(bait, .{ .mode = 0o700 })).close();
571
572 // Moving the cwd is safe here in a way the code cannot state: the test
573 // runner is sequential, each module gets its own test binary and so its
574 // own process, and no test after this one depends on the cwd.
575 var cwdbuf: [std.fs.max_path_bytes]u8 = undefined;
576 const orig = try std.posix.getcwd(&cwdbuf);
577 try std.posix.chdir(tmp.path());
578 // Runs before `tmp.cleanup()` (defers unwind in reverse), so the test
579 // is never standing in the directory it is deleting. Panics rather than
580 // swallowing: a cleanup that fails costs a stale tmp dir, but a failed
581 // chdir-BACK leaves every later relative path resolving somewhere
582 // nobody chose, so there is no honest way to keep going.
583 defer std.posix.chdir(orig) catch |err|
584 std.debug.panic("spawn test: could not chdir back to {s}: {t}", .{ orig, err });
585
586 try std.testing.expectEqual(
587 @as(?[]const u8, null),
588 try findInPath(alloc, "::", "muxd"),
589 );
590 try std.testing.expectEqual(
591 @as(?[]const u8, null),
592 try findInPath(alloc, "", "muxd"),
593 );
594 }
595
596 // Forces semantic analysis of every pub decl under `zig build test`, so an 547 // Forces semantic analysis of every pub decl under `zig build test`, so an
597 // unreferenced decl must at least compile (the silent-module-loss hazard, 548 // unreferenced decl must at least compile (the silent-module-loss hazard,
598 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 549 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.