a73x

fe5397f2

refactor: the daemon starts itself, and every other starter asks in words

a73x   2026-08-30 07:58

Commit message
refactor: the daemon starts itself, and every other starter asks in words

`spawn.ensureDaemon` let its callers choose the daemon's argv, its
deadline and what became of its log — three decisions none of them can
see the consequences of, and the client owned two. The fork, the log and
the up-line move into `mux d`, beside the flags they are about; the
client's own entry runs `mux d start -d --sock PATH` with its fd 2 lent
to it, so a refusal reaches the user in the daemon's own words and in
under a second instead of as a two-second silence.

build.zig grows rule 6 to say so: `posix.fork(` under src/ is main.zig's
alone. spawn.zig is what is left when the deciding goes — this image, as
a path something can exec.

The log is APPENDED now, having been the caller's choice and both ways.
One xdg log serves every socket on the box, so a start that truncated
would zero the log of a daemon still writing to it; the e2e pin says so
from the other side.

build.zig
Old New
@@ -168,10 +168,10 @@ const mod_table = [_]ModSpec{
168 // machine be exercised without a terminal — or a daemon — anywhere in 168 // machine be exercised without a terminal — or a daemon — anywhere in
169 // the picture; reconcile takes its grid duck-typed instead. 169 // the picture; reconcile takes its grid duck-typed instead.
170 .{ .name = "predict", .path = "src/engine/predict.zig", .layer = 1, .imports = &.{"protocol"} }, 170 .{ .name = "predict", .path = "src/engine/predict.zig", .layer = 1, .imports = &.{"protocol"} },
171 // Daemon spawning (probe / detach / poll). Under src/cli/ because its 171 // This image, as a path something can exec. Under src/cli/ because it
172 // only callers are the two mains, and because it asks the OS whether it 172 // asks the OS about the process it is in — a question no headless
173 // has a terminal — a question no headless client may spell. 173 // client may spell.
174 .{ .name = "spawn", .path = "src/cli/spawn.zig", .layer = 1, .link_libc = true, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} }, 174 .{ .name = "spawn", .path = "src/cli/spawn.zig", .layer = 1, .link_libc = true },
175 // The ssh→QUIC handoff's shared vocabulary: the announce line, the 175 // The ssh→QUIC handoff's shared vocabulary: the announce line, the
176 // per-host cache, the dial-host strip. Both binaries import it — 176 // per-host cache, the dial-host strip. Both binaries import it —
177 // `muxd endpoint` writes the line, `mux HOST` reads it. xdg is for 177 // `muxd endpoint` writes the line, `mux HOST` reads it. xdg is for
@@ -448,6 +448,11 @@ const SourceBan = struct {
448 needles: []const []const u8, 448 needles: []const []const u8,
449 /// What is wrong with spelling it, in the fatal's own voice. 449 /// What is wrong with spelling it, in the fatal's own voice.
450 why: []const u8, 450 why: []const u8,
451 /// The one file a rule is ABOUT rather than against: rule 6 exists to
452 /// say WHERE the fork lives, so naming that file here is the rule's
453 /// content and not a hole in it. Unlike the in-file `exemption:` line,
454 /// which any file may write for itself, this is a diff to build.zig.
455 except: ?[]const u8 = null,
451 }; 456 };
452 457
453 const source_bans = [_]SourceBan{ 458 const source_bans = [_]SourceBan{
@@ -475,6 +480,16 @@ const source_bans = [_]SourceBan{
475 "words — and every one is exec'd as argv, so no shell of ours " ++ 480 "words — and every one is exec'd as argv, so no shell of ours " ++
476 "ever parses a line we built", 481 "ever parses a line we built",
477 }, 482 },
483 .{
484 .rule = "6",
485 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli" },
486 .needles = &.{"posix.fork("},
487 .except = "src/cli/main.zig",
488 .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++
489 "every other starter spells that argv and execs this image. A " ++
490 "client that forked a daemon would be choosing the daemon's " ++
491 "flags, its log and its refusals, none of which it can see",
492 },
478 }; 493 };
479 494
480 fn checkSourceBan(b: *std.Build, ban: SourceBan) void { 495 fn checkSourceBan(b: *std.Build, ban: SourceBan) void {
@@ -483,6 +498,7 @@ fn checkSourceBan(b: *std.Build, ban: SourceBan) void {
483 var paths: std.ArrayList([]const u8) = .empty; 498 var paths: std.ArrayList([]const u8) = .empty;
484 zigFilesIn(b, sub, &paths); 499 zigFilesIn(b, sub, &paths);
485 for (paths.items) |path| { 500 for (paths.items) |path| {
501 if (ban.except) |ex| if (std.mem.eql(u8, path, ex)) continue;
486 const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e| 502 const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e|
487 fatal("folder rule {s}: cannot read {s} ({s})", .{ ban.rule, path, @errorName(e) }); 503 fatal("folder rule {s}: cannot read {s} ({s})", .{ ban.rule, path, @errorName(e) });
488 if (std.mem.indexOf(u8, src, exempt) != null) continue; 504 if (std.mem.indexOf(u8, src, exempt) != null) continue;
src/cli/main.zig
Old New
@@ -779,10 +779,10 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
779 const peer = peerPid(stream.handle); 779 const peer = peerPid(stream.handle);
780 stream.close(); 780 stream.close();
781 781
782 // Probe-first, deadline-second — ensureDaemon's poll shape (spawn.zig), 782 // Probe-first, deadline-second — `forkDaemon`'s poll shape, so the
783 // so the final window before the deadline is still probed and the 783 // final window before the deadline is still probed and the failure
784 // failure line is never printed about an interval nobody checked. 784 // line is never printed about an interval nobody checked.
785 // spawn.probe is the connect-refusal test: a live listener's backlog 785 // `sockpath.answers` is the connect-refusal test: a live listener's backlog
786 // accepts even when its event loop is wedged (up to backlog depth — 786 // accepts even when its event loop is wedged (up to backlog depth —
787 // past ~128 pending connects AF_UNIX blocks rather than refuses, so 787 // past ~128 pending connects AF_UNIX blocks rather than refuses, so
788 // this loop would wait, not lie), and only the shutdown unlink can 788 // this loop would wait, not lie), and only the shutdown unlink can
@@ -790,7 +790,7 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
790 const stop_deadline_ms: i64 = 2000; 790 const stop_deadline_ms: i64 = 2000;
791 const t0 = std.time.milliTimestamp(); 791 const t0 = std.time.milliTimestamp();
792 while (true) { 792 while (true) {
793 if (!spawn.probe(sock_path)) return waitPidGone(peer, sock_path); 793 if (!sockpath.answers(sock_path)) return waitPidGone(peer, sock_path);
794 if (std.time.milliTimestamp() - t0 >= stop_deadline_ms) break; 794 if (std.time.milliTimestamp() - t0 >= stop_deadline_ms) break;
795 std.Thread.sleep(50 * std.time.ns_per_ms); 795 std.Thread.sleep(50 * std.time.ns_per_ms);
796 } 796 }
@@ -935,7 +935,7 @@ fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool)
935 935
936 /// The socket answered, and the answer came from the new image. 936 /// The socket answered, and the answer came from the new image.
937 fn confirmServing(alloc: std.mem.Allocator, sock_path: []const u8) u8 { 937 fn confirmServing(alloc: std.mem.Allocator, sock_path: []const u8) u8 {
938 // Not `spawn.probe`: the listener fd crosses the exec, so a connect 938 // Not `sockpath.answers`: the listener fd crosses the exec, so a connect
939 // succeeds throughout the handover — it is served out of the backlog by 939 // succeeds throughout the handover — it is served out of the backlog by
940 // whichever image accepts it. Only an ANSWERED frame says the new one 940 // whichever image accepts it. Only an ANSWERED frame says the new one
941 // is pumping. 941 // is pumping.
@@ -1028,14 +1028,11 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posi
1028 // path this verb is about to probe has to be the path the daemon binds. 1028 // path this verb is about to probe has to be the path the daemon binds.
1029 // Without it (`&.{}`) a `mux d endpoint --sock PATH --start` starts a 1029 // Without it (`&.{}`) a `mux d endpoint --sock PATH --start` starts a
1030 // daemon on the DEFAULT socket, probes PATH, finds nothing and exits 1 1030 // daemon on the DEFAULT socket, probes PATH, finds nothing and exits 1
1031 // — a stray daemon nothing reports. `spawn.ensureForAttach` forwards it 1031 // — a stray daemon nothing reports. The client's own attach spells it
1032 // for the same reason. Nothing else is: a bare daemon binds its QUIC 1032 // for the same reason. Nothing else is: a bare daemon binds its QUIC
1033 // listener when the announce below asks it to, which is how a plain 1033 // listener when the announce below asks it to, which is how a plain
1034 // `mux d start -d` already produces a port. 1034 // `mux d start -d` already produces a port.
1035 // 1035 //
1036 // Truncating, like `mux d start -d`: the user asked for a daemon, so the
1037 // log they go on to read must be about the one they just got.
1038 //
1039 // A spawn that failed has already said so on stderr, which ssh carries 1036 // A spawn that failed has already said so on stderr, which ssh carries
1040 // to the asker; returning here leaves the announce UNWRITTEN, and an 1037 // to the asker; returning here leaves the announce UNWRITTEN, and an
1041 // absent announce is the "no daemon" shape the client already reads as 1038 // absent announce is the "no daemon" shape the client already reads as
@@ -1045,8 +1042,7 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posi
1045 const sock_z = try alloc.dupeZ(u8, sock_path); 1042 const sock_z = try alloc.dupeZ(u8, sock_path);
1046 defer alloc.free(sock_z); 1043 defer alloc.free(sock_z);
1047 const run_args = [_][:0]const u8{ "--sock", sock_z }; 1044 const run_args = [_][:0]const u8{ "--sock", sock_z };
1048 if (spawn.ensure(alloc, &run_args, sock_path, "mux d endpoint", .{ .truncate = true }) == null) 1045 if (startDetached(alloc, &run_args, sock_path, "mux d endpoint") == null) return 1;
1049 return 1;
1050 } 1046 }
1051 1047
1052 // Silent otherwise, and that is the whole of the verb's manners: the 1048 // Silent otherwise, and that is the whole of the verb's manners: the
@@ -1054,7 +1050,7 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posi
1054 // stderr here is a word on the wall's alternate screen. The poll spells 1050 // stderr here is a word on the wall's alternate screen. The poll spells
1055 // the verb WITHOUT `--start`, which is what keeps a read from starting 1051 // the verb WITHOUT `--start`, which is what keeps a read from starting
1056 // anything — the rule is argv's now, not a branch in the client. 1052 // anything — the rule is argv's now, not a branch in the client.
1057 if (!spawn.probe(sock_path)) return 1; 1053 if (!sockpath.answers(sock_path)) return 1;
1058 1054
1059 // The announce goes out on the same stdout the pump is about to use, 1055 // The announce goes out on the same stdout the pump is about to use,
1060 // so it wants the same EPIPE-not-SIGPIPE treatment — and it wants it 1056 // so it wants the same EPIPE-not-SIGPIPE treatment — and it wants it
@@ -1226,7 +1222,7 @@ fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 {
1226 .endpoint_req, 1222 .endpoint_req,
1227 "", 1223 "",
1228 .endpoint_reply, 1224 .endpoint_reply,
1229 spawn.start_deadline_ms, 1225 start_deadline_ms,
1230 .is_the_answer, 1226 .is_the_answer,
1231 ) catch null) orelse return 0; 1227 ) catch null) orelse return 0;
1232 defer frame.deinit(alloc); 1228 defer frame.deinit(alloc);
@@ -1243,11 +1239,7 @@ fn startCmd(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8, forwarded:
1243 if (!o.detach) return run(alloc, o, sock_path); 1239 if (!o.detach) return run(alloc, o, sock_path);
1244 const child_args = try withoutDetach(alloc, forwarded); 1240 const child_args = try withoutDetach(alloc, forwarded);
1245 defer alloc.free(child_args); 1241 defer alloc.free(child_args);
1246 // Default log path: the xdg one is the whole point for a real daemon. 1242 const r = startDetached(alloc, child_args, sock_path, "mux d") orelse return 1;
1247 // Truncating, and this is the only caller that does: `-d` is the one
1248 // flag whose user asked for a (re)start, so the log they go on to read
1249 // must be about the daemon they just started.
1250 const r = spawn.ensure(alloc, child_args, sock_path, "mux d", .{ .truncate = true }) orelse return 1;
1251 if (r == .already_running) { 1243 if (r == .already_running) {
1252 std.debug.print( 1244 std.debug.print(
1253 "mux d: already running on {s} (stop it first with `mux d stop --sock {s}` if you meant different flags)\n", 1245 "mux d: already running on {s} (stop it first with `mux d stop --sock {s}` if you meant different flags)\n",
@@ -1297,6 +1289,230 @@ fn takesValue(word: []const u8) bool {
1297 return false; 1289 return false;
1298 } 1290 }
1299 1291
1292 /// How long a spawn gets to answer. One number for both starters, because
1293 /// a user who waited two seconds for `mux d start -d` must not wait a
1294 /// different two seconds for the attach that starts the same daemon the
1295 /// same way: it describes how long the daemon takes to bind, which is not
1296 /// a fact about which verb asked for it.
1297 const start_deadline_ms: u32 = 2000;
1298
1299 const Started = enum { already_running, started };
1300
1301 const StartError = error{ SpawnFailed, NeverAnswered };
1302
1303 /// All of `forkDaemon`'s stderr output belongs to this struct: the caller
1304 /// decides the prefix (the verb the user typed) and whether dots animate.
1305 /// Silence on the already-running path is part of the contract — any
1306 /// output at all means something unusual happened.
1307 const Progress = struct {
1308 fd: std.posix.fd_t,
1309 prefix: []const u8,
1310 tty: bool,
1311
1312 fn emit(self: Progress, s: []const u8) void {
1313 _ = std.posix.write(self.fd, s) catch {};
1314 }
1315
1316 fn emitFmt(self: Progress, comptime fmt: []const u8, args: anytype) void {
1317 var buf: [256]u8 = undefined;
1318 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
1319 self.emit(s);
1320 }
1321 };
1322
1323 /// Test hook: the pid of the most recent spawn; this file's tests are its
1324 /// only readers, reaping the deliberately-orphaned stub by it. Not
1325 /// synchronized — every caller is single-threaded.
1326 var last_spawned_pid: std.posix.pid_t = 0;
1327
1328 /// `-d`, all of it: probe, fork, exec this image in the child, poll until
1329 /// the socket answers.
1330 fn forkDaemon(
1331 alloc: std.mem.Allocator,
1332 exe_path: []const u8,
1333 run_args: []const [:0]const u8,
1334 sock_path: []const u8,
1335 progress: Progress,
1336 deadline_ms: u32,
1337 log_path_override: ?[]const u8,
1338 ) StartError!Started {
1339 // The one fork under src/, and build.zig's rule 6 is what keeps it
1340 // that way: the daemon's flags, its log and its refusals are the
1341 // daemon's own business, and a client that forked one would be a
1342 // second decider on all three without being able to see any of them.
1343 //
1344 // `NeverAnswered` does not kill the spawned pid: a daemon up at 2.5s
1345 // is there for the retry. Racers sort themselves out — the loser exits
1346 // on DaemonAlreadyRunning.
1347 if (sockpath.answers(sock_path)) return .already_running;
1348
1349 std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed;
1350
1351 // Owned either way, so one `free` covers both: a caller-supplied path is
1352 // duped rather than borrowed, because the xdg branch must allocate.
1353 const log_path = if (log_path_override) |p|
1354 alloc.dupe(u8, p) catch return error.SpawnFailed
1355 else
1356 xdg.logPath(alloc) catch return error.SpawnFailed;
1357 defer alloc.free(log_path);
1358 if (std.fs.path.dirname(log_path)) |dir|
1359 std.fs.cwd().makePath(dir) catch return error.SpawnFailed;
1360 // APPEND, always, and opened by hand rather than through createFile
1361 // because O_APPEND is what createFile cannot ask for. One xdg log
1362 // serves every socket on the box, so truncating would zero a daemon
1363 // somebody else is still writing to; the kernel's atomic seek-to-end
1364 // is the whole of what makes the shared file safe. "Do not truncate"
1365 // alone would be worse than truncating — the child's fd would start at
1366 // offset zero and overwrite the log from the front.
1367 const log: std.fs.File = .{
1368 .handle = std.posix.open(log_path, .{
1369 .ACCMODE = .WRONLY,
1370 .CREAT = true,
1371 .APPEND = true,
1372 // createFile set this for free and posix.O does not, which is
1373 // exactly how hand-rolling the open lost it. Without it the
1374 // original fd survives the exec below into the long-lived daemon
1375 // and rides a second exec into the user's shell; fds 1 and 2 are
1376 // safe only because dup2 clears FD_CLOEXEC on its targets.
1377 .CLOEXEC = true,
1378 }, 0o600) catch return error.SpawnFailed,
1379 };
1380 defer log.close();
1381 const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed;
1382 defer devnull.close();
1383
1384 // argv for the child: mux d start <forwarded...>, all null-terminated.
1385 // The mode word is spelled out so `ps` shows a daemon as a daemon —
1386 // it is the only thing separating the long-lived process from the
1387 // `mux d start -d` that spawned it.
1388 const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed;
1389 defer alloc.free(exe_z);
1390 const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 3, null) catch
1391 return error.SpawnFailed;
1392 defer alloc.free(argv);
1393 argv[0] = "mux";
1394 argv[1] = "d";
1395 argv[2] = "start";
1396 for (run_args, 0..) |a, i| argv[i + 3] = a.ptr;
1397
1398 progress.emitFmt("{s}: starting\u{2026}", .{progress.prefix});
1399 if (!progress.tty) progress.emit("\n");
1400
1401 const t0 = std.time.milliTimestamp();
1402 const pid = std.posix.fork() catch {
1403 if (progress.tty) progress.emit("\n");
1404 return error.SpawnFailed;
1405 };
1406 if (pid == 0) {
1407 // Child: its own session, no controlling terminal, stdio detached.
1408 // Nothing here may allocate or return — only exec or _exit.
1409 //
1410 // exit_group, never std.posix.exit: we link libc, so the latter is
1411 // exit(3), which runs atexit handlers (Zig's runtime, wolfSSL's)
1412 // and flushes stdio buffers — buffers this process inherited from
1413 // the PARENT at fork, so the parent's pending output would be
1414 // written a second time by its own child. exit_group is the raw
1415 // syscall and skips all of it. Only reachable if dup2 or exec
1416 // fails, which is exactly when the least machinery should run.
1417 _ = std.os.linux.setsid();
1418 std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch
1419 std.os.linux.exit_group(127);
1420 std.posix.dup2(log.handle, std.posix.STDOUT_FILENO) catch
1421 std.os.linux.exit_group(127);
1422 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch
1423 std.os.linux.exit_group(127);
1424 // An exec rather than simply running the daemon in this fork, and
1425 // that is not a preference: `std.debug.MemoryAccessor` caches the
1426 // pid it reads memory through, so a forked child's first
1427 // DebugAllocator stack trace calls `process_vm_readv` on the
1428 // parent, gets ESRCH, and panics on `unreachable // own pid is
1429 // always valid`. Measured, deterministic, and invisible until the
1430 // daemon has been up long enough to allocate.
1431 //
1432 // execveZ's return type IS an error set — there is no success value,
1433 // because success does not return. 127 is the shell's "cannot exec",
1434 // and the parent learns the same thing either way: the socket never
1435 // answers, and the log names what happened.
1436 switch (std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ)) {
1437 else => std.os.linux.exit_group(127),
1438 }
1439 }
1440 last_spawned_pid = pid;
1441
1442 // Parent: poll. Dots only on a tty so scripted output stays pinnable.
1443 var next_dot: i64 = t0 + 250;
1444 // A pid owes us exactly one reap. Calling waitpid again after it has
1445 // been reaped gets ECHILD, which std.posix.waitpid answers with
1446 // `unreachable` — so the second call is not an error to handle but a
1447 // panic, and the panic lands precisely on the path that exists to
1448 // report a child that died young (a missing key file, a bad bind
1449 // address). Tracking the reap is what keeps that path a message.
1450 var reaped = false;
1451 while (true) {
1452 if (sockpath.answers(sock_path)) {
1453 const secs = @as(f64, @floatFromInt(std.time.milliTimestamp() - t0)) / 1000.0;
1454 // The leading space exists to follow the dots on a tty. There
1455 // are no dots on a non-tty — the ssh proxy's case, and now the
1456 // common one — where it would only be a stray space at the
1457 // start of a scripted line.
1458 if (progress.tty) progress.emit(" ");
1459 progress.emitFmt("up ({d:.1}s) pid={d}\n", .{ secs, pid });
1460 return .started;
1461 }
1462 const now = std.time.milliTimestamp();
1463 if (now - t0 >= deadline_ms) {
1464 // The newline terminates the dot line, so it belongs to the
1465 // same condition the dots do: on a non-tty there are no dots
1466 // and it would only put a blank line into scripted output.
1467 if (progress.tty) progress.emit("\n");
1468 // "daemon" in the body, because the prefix is already the
1469 // program speaking: `mux d: daemon did not answer` reads
1470 // correctly and would not survive naming the binary twice.
1471 progress.emitFmt(
1472 "{s}: daemon did not answer within {d}s \u{2014} log: {s}\n",
1473 .{ progress.prefix, deadline_ms / 1000, log_path },
1474 );
1475 return error.NeverAnswered;
1476 }
1477 if (progress.tty and now >= next_dot) {
1478 progress.emit(".");
1479 next_dot = now + 250;
1480 }
1481 // Reap if the child exited (loser of a start race, or a refused
1482 // flag): its socket-owner sibling answers the next probe either
1483 // way, and an unreaped child would sit as a zombie until we exit.
1484 // Once is enough, and once is all that is safe — see `reaped`.
1485 if (!reaped and std.posix.waitpid(pid, std.posix.W.NOHANG).pid == pid)
1486 reaped = true;
1487 std.Thread.sleep(50 * std.time.ns_per_ms);
1488 }
1489 }
1490
1491 /// Both starters — `-d` and `endpoint --start` — through one door: this
1492 /// image, the one deadline, a failure already narrated. Null needs only an
1493 /// exit code.
1494 fn startDetached(alloc: std.mem.Allocator, run_args: []const [:0]const u8, sock_path: []const u8, prefix: []const u8) ?Started {
1495 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
1496 const exe = spawn.selfExe(&exe_buf);
1497 const progress: Progress = .{
1498 .fd = std.posix.STDERR_FILENO,
1499 .prefix = prefix,
1500 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
1501 };
1502 return forkDaemon(alloc, exe, run_args, sock_path, progress, start_deadline_ms, null) catch |err| {
1503 // NeverAnswered's failure line, with the log path, was already
1504 // printed by Progress — a second line would say the same thing
1505 // worse. A spawn that never happened has nothing printed yet, and
1506 // std.debug.print rather than progress.emitFmt because an exe path
1507 // can run to max_path_bytes and emitFmt's fixed buffer would drop
1508 // the whole line rather than shorten it. The RESOLVED path, so the
1509 // line names a file an operator can stat.
1510 if (err == error.SpawnFailed)
1511 std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, exe, @errorName(err) });
1512 return null;
1513 };
1514 }
1515
1300 fn keygen(alloc: std.mem.Allocator) !u8 { 1516 fn keygen(alloc: std.mem.Allocator) !u8 {
1301 const path = try xdg.keyPath(alloc); 1517 const path = try xdg.keyPath(alloc);
1302 defer alloc.free(path); 1518 defer alloc.free(path);
@@ -1839,7 +2055,7 @@ test "endpointCmd: a box with no daemon is refused, never started — the wall p
1839 @as(u8, 1), 2055 @as(u8, 1),
1840 // `false` is the poll's spelling, and it is the one under test: the 2056 // `false` is the poll's spelling, and it is the one under test: the
1841 // asking spelling forks a daemon, which no unit test may do here — 2057 // asking spelling forks a daemon, which no unit test may do here —
1842 // `spawn.ensure` takes the XDG log path with no override, so a 2058 // `startDetached` takes the XDG log path with no override, so a
1843 // `true` would truncate the operator's live `muxd.log`. That half 2059 // `true` would truncate the operator's live `muxd.log`. That half
1844 // is e2e's (`e2e_04_handoff.sh` cold leg, `e2e_09_hosts.sh`). 2060 // is e2e's (`e2e_04_handoff.sh` cold leg, `e2e_09_hosts.sh`).
1845 try endpointCmd(std.testing.allocator, sock, out.handle, false), 2061 try endpointCmd(std.testing.allocator, sock, out.handle, false),
@@ -1847,7 +2063,7 @@ test "endpointCmd: a box with no daemon is refused, never started — the wall p
1847 // The verb READS a box without `--start`. Starting a daemon here gave a 2063 // The verb READS a box without `--start`. Starting a daemon here gave a
1848 // listed machine one (and a shell in session 0) from a poll, and undid a 2064 // listed machine one (and a shell in session 0) from a poll, and undid a
1849 // `mux d stop` on the next cycle a second later. 2065 // `mux d stop` on the next cycle a second later.
1850 try std.testing.expect(!spawn.probe(sock)); 2066 try std.testing.expect(!sockpath.answers(sock));
1851 try std.testing.expectEqual(@as(u64, 0), (try out.stat()).size); 2067 try std.testing.expectEqual(@as(u64, 0), (try out.stat()).size);
1852 } 2068 }
1853 2069
@@ -2024,6 +2240,213 @@ test "shellIntegrationEnabled: `1` and nothing else turns it on" {
2024 try std.testing.expect(!shellIntegrationEnabled("yes")); 2240 try std.testing.expect(!shellIntegrationEnabled("yes"));
2025 } 2241 }
2026 2242
2243 fn silentProgress() Progress {
2244 // Progress that writes to /dev/null keeps test output clean while the
2245 // pinned-output case below captures a pipe instead.
2246 const f = std.fs.cwd().openFile("/dev/null", .{ .mode = .write_only }) catch unreachable;
2247 return .{ .fd = f.handle, .prefix = "test", .tty = false };
2248 }
2249
2250 test "start -d: an answering socket is already_running, nothing spawned" {
2251 const testtmp = @import("testtmp");
2252 var tmp = try testtmp.TmpDir.make();
2253 defer tmp.cleanup();
2254 var buf: [128]u8 = undefined;
2255 const sock = try std.fmt.bufPrint(&buf, "{s}/live.sock", .{tmp.path()});
2256
2257 const addr = try std.net.Address.initUnix(sock);
2258 var server = try addr.listen(.{});
2259 defer server.deinit();
2260
2261 // Progress captured through a pipe: already_running must print NOTHING.
2262 const pipe = try std.posix.pipe();
2263 defer std.posix.close(pipe[0]);
2264 const progress: Progress = .{ .fd = pipe[1], .prefix = "test", .tty = false };
2265
2266 const r = try forkDaemon(
2267 std.testing.allocator,
2268 "/definitely/not/consulted",
2269 &.{},
2270 sock,
2271 progress,
2272 200,
2273 null,
2274 );
2275 try std.testing.expectEqual(Started.already_running, r);
2276
2277 std.posix.close(pipe[1]);
2278 var out: [64]u8 = undefined;
2279 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &out));
2280 }
2281
2282 test "start -d: a missing binary is SpawnFailed before any fork" {
2283 const testtmp = @import("testtmp");
2284 var tmp = try testtmp.TmpDir.make();
2285 defer tmp.cleanup();
2286 var buf: [128]u8 = undefined;
2287 const sock = try std.fmt.bufPrint(&buf, "{s}/none.sock", .{tmp.path()});
2288 try std.testing.expectError(error.SpawnFailed, forkDaemon(
2289 std.testing.allocator,
2290 "/no/such/mux",
2291 &.{},
2292 sock,
2293 silentProgress(),
2294 200,
2295 null,
2296 ));
2297 }
2298
2299 test "start -d: a child that dies young is reported, not panicked on" {
2300 const testtmp = @import("testtmp");
2301 var tmp = try testtmp.TmpDir.make();
2302 defer tmp.cleanup();
2303 var pbuf: [128]u8 = undefined;
2304 var sbuf: [128]u8 = undefined;
2305 var lbuf: [128]u8 = undefined;
2306 const stub = try std.fmt.bufPrint(&pbuf, "{s}/dies.sh", .{tmp.path()});
2307 const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()});
2308 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
2309
2310 // Exits at once, binding nothing — a daemon refusing a flag, or one
2311 // whose key file is missing. The poll loop therefore reaps it on an
2312 // early pass and keeps polling to the deadline, which is where a
2313 // second waitpid would get ECHILD and panic.
2314 try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" });
2315 const f = try tmp.dir.openFile("dies.sh", .{});
2316 try f.chmod(0o755);
2317 f.close();
2318
2319 try std.testing.expectError(error.NeverAnswered, forkDaemon(
2320 std.testing.allocator,
2321 stub,
2322 &.{},
2323 sock,
2324 silentProgress(),
2325 300,
2326 log,
2327 ));
2328 // The log is still there to be named by the failure line: a child that
2329 // died is exactly when an operator goes looking for it.
2330 const log_st = try std.fs.cwd().statFile(log);
2331 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
2332 }
2333
2334 test "start -d: a binary that never binds is NeverAnswered, pid left alive" {
2335 const testtmp = @import("testtmp");
2336 var tmp = try testtmp.TmpDir.make();
2337 defer tmp.cleanup();
2338 var pbuf: [128]u8 = undefined;
2339 var sbuf: [128]u8 = undefined;
2340 var lbuf: [128]u8 = undefined;
2341 const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
2342 const sock = try std.fmt.bufPrint(&sbuf, "{s}/never.sock", .{tmp.path()});
2343 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
2344
2345 // A stand-in daemon that stays alive and binds nothing. `exec` so the
2346 // pid this tracked IS the sleeper, not a parent shell of it.
2347 try tmp.dir.writeFile(.{ .sub_path = "stub.sh", .data = "#!/bin/sh\nexec sleep 30\n" });
2348 const f = try tmp.dir.openFile("stub.sh", .{});
2349 try f.chmod(0o755);
2350 f.close();
2351
2352 // The log goes somewhere disposable. xdg.logPath reads XDG_STATE_HOME
2353 // at call time and Zig tests cannot setenv, so leaving this null would
2354 // append to the real `~/.local/state/mux/muxd.log` — a LIVE daemon's
2355 // log, grown by `make test`. The nested `logs/` component also proves
2356 // the parent directory is created rather than assumed.
2357 const t0 = std.time.milliTimestamp();
2358 try std.testing.expectError(error.NeverAnswered, forkDaemon(
2359 std.testing.allocator,
2360 stub,
2361 &.{},
2362 sock,
2363 silentProgress(),
2364 300,
2365 log,
2366 ));
2367 // The log is created before the fork, so it exists even when the child
2368 // never writes to it — that is what makes it the place to look when a
2369 // spawn fails.
2370 const log_st = try std.fs.cwd().statFile(log);
2371 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
2372 // It waited the deadline out rather than bailing early...
2373 try std.testing.expect(std.time.milliTimestamp() - t0 >= 300);
2374
2375 // ...and did NOT kill the spawned process. `last_spawned_pid` is how
2376 // the test learns which pid that is: killing by anything else — a name
2377 // match, a process sweep — could take out a bystander, so the pid the
2378 // spawner tracked is the only handle allowed.
2379 try std.testing.expect(last_spawned_pid != 0);
2380 // waitpid with NOHANG returning pid 0 means "child exists, still
2381 // running", which is the assertion; a reaped or dead child returns its
2382 // own pid instead.
2383 try std.testing.expectEqual(
2384 @as(std.posix.pid_t, 0),
2385 std.posix.waitpid(last_spawned_pid, std.posix.W.NOHANG).pid,
2386 );
2387 std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
2388 _ = std.posix.waitpid(last_spawned_pid, 0);
2389 }
2390
2391 test "start -d: the child execs the path it was HANDED, never a name off PATH" {
2392 const testtmp = @import("testtmp");
2393 var tmp = try testtmp.TmpDir.make();
2394 defer tmp.cleanup();
2395 var pbuf: [160]u8 = undefined;
2396 var sbuf: [160]u8 = undefined;
2397 var lbuf: [160]u8 = undefined;
2398 var rbuf: [160]u8 = undefined;
2399 const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
2400 const sock = try std.fmt.bufPrint(&sbuf, "{s}/self.sock", .{tmp.path()});
2401 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/mux.log", .{tmp.path()});
2402 const seen = try std.fmt.bufPrint(&rbuf, "{s}/child.exe", .{tmp.path()});
2403
2404 // `$0` is the kernel's answer to "which file did you exec": for a
2405 // shebang script it is the script, whatever argv[0] the caller wrote.
2406 // Nothing on this box resolves by NAME to a file in a fresh tmp dir, so
2407 // a spawn that searched PATH cannot pass this. `$*` pins the other half
2408 // — the daemon is asked for `d start`, which is also what makes a daemon
2409 // legible in `ps`. Production hands `spawn.selfExe` and nothing else, so
2410 // the same mechanism cannot reach a sibling: `mux` used to hunt PATH for
2411 // a `muxd`, and an e2e leg graded an installed v0.0.1-10 that way.
2412 var script: [512]u8 = undefined;
2413 try tmp.dir.writeFile(.{
2414 .sub_path = "stub.sh",
2415 .data = try std.fmt.bufPrint(&script,
2416 \\#!/bin/sh
2417 \\printf '%s|%s' "$0" "$*" > "{s}"
2418 \\exec sleep 30
2419 \\
2420 , .{seen}),
2421 });
2422 const f = try tmp.dir.openFile("stub.sh", .{});
2423 try f.chmod(0o755);
2424 f.close();
2425
2426 try std.testing.expectError(error.NeverAnswered, forkDaemon(
2427 std.testing.allocator,
2428 stub,
2429 &.{},
2430 sock,
2431 silentProgress(),
2432 400,
2433 log,
2434 ));
2435 defer {
2436 std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
2437 _ = std.posix.waitpid(last_spawned_pid, 0);
2438 }
2439
2440 var got_buf: [std.fs.max_path_bytes]u8 = undefined;
2441 // Read through the failure rather than around it: a child that never
2442 // ran the stub at all must fail as a MISMATCH naming what it became,
2443 // not as a FileNotFound three frames inside std.
2444 const got = std.fs.cwd().readFile(seen, &got_buf) catch "<the child ran something else>";
2445 var want_buf: [200]u8 = undefined;
2446 const want = try std.fmt.bufPrint(&want_buf, "{s}|d start", .{stub});
2447 try std.testing.expectEqualStrings(want, std.mem.trimRight(u8, got, "\n"));
2448 }
2449
2027 // Forces semantic analysis of every pub decl under `zig build test`, so an 2450 // Forces semantic analysis of every pub decl under `zig build test`, so an
2028 // unreferenced decl must at least compile (the silent-module-loss hazard, 2451 // unreferenced decl must at least compile (the silent-module-loss hazard,
2029 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 2452 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
src/cli/mux_main.zig
Old New
@@ -363,7 +363,7 @@ pub fn main(args: []const [:0]const u8) !u8 {
363 fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool { 363 fn localNeedsStart(h: *const hosts.Hosts, sock: []const u8) bool {
364 var buf: [sockpath.max_sun_path + "--sock ".len]u8 = undefined; 364 var buf: [sockpath.max_sun_path + "--sock ".len]u8 = undefined;
365 const line = std.fmt.bufPrint(&buf, "--sock {s}", .{sock}) catch return false; 365 const line = std.fmt.bufPrint(&buf, "--sock {s}", .{sock}) catch return false;
366 return h.has(line) and !spawn.probe(sock); 366 return h.has(line) and !sockpath.answers(sock);
367 } 367 }
368 368
369 /// The local socket's one door: `mux --sock PATH` and an empty hosts file. 369 /// The local socket's one door: `mux --sock PATH` and an empty hosts file.
@@ -399,7 +399,7 @@ fn attachLocal(
399 // was doomed at parse. 399 // was doomed at parse.
400 if (sockpath.tooLong("mux", sock_path)) return 1; 400 if (sockpath.tooLong("mux", sock_path)) return 1;
401 401
402 if (!try spawn.ensureForAttach(alloc, sock_path, "mux")) return 1; 402 if (!sockpath.answers(sock_path) and !try startLocalDaemon(alloc, sock_path)) return 1;
403 return wallview.runAttach( 403 return wallview.runAttach(
404 alloc, 404 alloc,
405 .{ .sock = sock_path }, 405 .{ .sock = sock_path },
@@ -410,6 +410,25 @@ fn attachLocal(
410 ); 410 );
411 } 411 }
412 412
413 /// Ask the daemon to start itself: `mux d start -d --sock PATH`, this
414 /// image, stdio inherited. An ASK, not a fork — the daemon owns its own
415 /// flags, its own log and its own refusals, and every one of them reaches
416 /// the user's terminal because this process lent the child its fd 2.
417 ///
418 /// Bare beyond `--sock`, and the reason is the whole of why an attach is
419 /// not a `--quic` start: a listener must be asked for, never appear
420 /// because someone attached.
421 fn startLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !bool {
422 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
423 const argv = [_][]const u8{ spawn.selfExe(&exe_buf), "d", "start", "-d", "--sock", sock_path };
424 var child = std.process.Child.init(&argv, alloc);
425 const term = child.spawnAndWait() catch |err| {
426 std.debug.print("mux: could not run {s}: {s}\n", .{ argv[0], @errorName(err) });
427 return false;
428 };
429 return term == .Exited and term.Exited == 0;
430 }
431
413 /// `mux hosts add`'s own line: no flags of its own, every word a host 432 /// `mux hosts add`'s own line: no flags of its own, every word a host
414 /// spelling. It shares `usage` so a flag added here has to be documented 433 /// spelling. It shares `usage` so a flag added here has to be documented
415 /// there like any other. 434 /// there like any other.
@@ -458,7 +477,7 @@ fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
458 // Failure is not a refusal: the wall still opens and that host's 477 // Failure is not a refusal: the wall still opens and that host's
459 // poller keeps redialling, so a daemon started elsewhere shows up. 478 // poller keeps redialling, so a daemon started elsewhere shows up.
460 if (sockpath.defaultSockPath(arena) catch null) |sock| { 479 if (sockpath.defaultSockPath(arena) catch null) |sock| {
461 if (localNeedsStart(&h, sock)) _ = try spawn.ensureForAttach(alloc, sock, "mux"); 480 if (localNeedsStart(&h, sock)) _ = try startLocalDaemon(alloc, sock);
462 } 481 }
463 482
464 const key = std.posix.getenv(xdg.key_env); 483 const key = std.posix.getenv(xdg.key_env);
src/cli/spawn.zig
Old New
@@ -1,18 +1,13 @@
1 //! Get a daemon onto a socket path: probe, spawn detached, poll until it 1 //! This image, as a path something can exec: `/proc/self/exe` read THROUGH
2 //! answers. `mux d start -d` is explicit and spelled out; the LOCAL client's 2 //! to the file it names. Every production start hands execve this and never
3 //! own entry (`mux` with no host) is the one attach that may still start 3 //! a name resolved against PATH — that is the end of the ambient-PATH trap,
4 //! one, through `ensureForAttach` below. The remote verbs a client reaches 4 //! where an `execvp("muxd")` grades whatever release is installed and did
5 //! over ssh do not: reading a box must never create a session there. 5 //! (an e2e leg whose daemon had died ran a v0.0.1-10 with no agent code in
6 //! it and passed).
6 //! 7 //!
7 //! What it spawns is this image — `selfExe` below, the /proc link read 8 //! Under src/cli/ because it asks the OS about the process it is in, which
8 //! through to the file it names — and never a name resolved against PATH. 9 //! is a question a client module may not ask.
9 //! That is the end of the ambient-PATH trap: an `execvp("muxd")` grades
10 //! whatever release is installed, and did (an e2e leg whose daemon had died
11 //! ran a v0.0.1-10 with no agent code in it and passed).
12 const std = @import("std"); 10 const std = @import("std");
13 const xdg = @import("xdg");
14
15 pub const EnsureError = error{ SpawnFailed, NeverAnswered };
16 11
17 /// The kernel's link to the running image. Only the fallback: a spawn is 12 /// The kernel's link to the running image. Only the fallback: a spawn is
18 /// worth more than the name it will wear, so a readlink that fails still 13 /// worth more than the name it will wear, so a readlink that fails still
@@ -44,326 +39,9 @@ fn execOrLink(resolved: []const u8) []const u8 {
44 std.posix.access(resolved, std.posix.X_OK) catch return self_exe; 39 std.posix.access(resolved, std.posix.X_OK) catch return self_exe;
45 return resolved; 40 return resolved;
46 } 41 }
47 pub const Ensured = enum { already_running, started };
48
49 /// How long a spawn gets to answer, for every caller. One number because a
50 /// user who waited two seconds for `mux d start -d` must not wait a different
51 /// two seconds for an attach that starts the same daemon the same way: the
52 /// deadline describes how long muxd takes to bind, which is not a fact
53 /// about which verb asked for it.
54 pub const start_deadline_ms: u32 = 2000;
55
56 /// Where a spawned daemon's stdout+stderr go, and what becomes of whatever
57 /// is already there. `truncate` has no default on purpose — the callers
58 /// want opposite answers, so each one has to say which it means.
59 pub const LogSpec = struct {
60 /// null means the xdg default, which is what every real caller wants.
61 /// It is settable at all so tests can point it somewhere disposable:
62 /// the default path belongs to whatever daemon is running on the
63 /// machine, and writing over it from a unit test would punch a hole in
64 /// a live daemon's log.
65 path: ?[]const u8 = null,
66 /// True for the two verbs whose user asked for a daemon — `mux d start -d`
67 /// and `mux d endpoint --start`; both probe first, so a daemon already
68 /// writing its log is never truncated under it. Auto-start appends
69 /// instead: an attach is not a restart.
70 truncate: bool,
71 };
72
73 /// Test hook: the pid of the most recent spawn; this file's tests are its
74 /// only readers, reaping the deliberately-orphaned stub by it. Not
75 /// synchronized — every caller is single-threaded.
76 pub var last_spawned_pid: std.posix.pid_t = 0;
77
78 /// All of ensureDaemon's stderr output belongs to this struct: the caller
79 /// decides the prefix (the verb the user typed) and whether dots animate.
80 /// Silence on the already-running path is part of the contract — any
81 /// output at all means something unusual happened.
82 pub const Progress = struct {
83 fd: std.posix.fd_t,
84 prefix: []const u8,
85 tty: bool,
86
87 fn emit(self: Progress, s: []const u8) void {
88 _ = std.posix.write(self.fd, s) catch {};
89 }
90
91 fn emitFmt(self: Progress, comptime fmt: []const u8, args: anytype) void {
92 var buf: [256]u8 = undefined;
93 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
94 self.emit(s);
95 }
96 };
97
98 /// `NeverAnswered` does not kill the spawned pid: a daemon up at 2.5s is
99 /// there for the retry. Racers sort themselves out: the loser exits on
100 /// DaemonAlreadyRunning.
101 pub fn ensureDaemon(
102 alloc: std.mem.Allocator,
103 exe_path: []const u8,
104 run_args: []const [:0]const u8,
105 sock_path: []const u8,
106 progress: Progress,
107 deadline_ms: u32,
108 log_spec: LogSpec,
109 ) EnsureError!Ensured {
110 if (probe(sock_path)) return .already_running;
111
112 std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed;
113
114 // Owned either way, so one `free` covers both: a caller-supplied path is
115 // duped rather than borrowed, because the xdg branch must allocate.
116 const log_path = if (log_spec.path) |p|
117 alloc.dupe(u8, p) catch return error.SpawnFailed
118 else
119 xdg.logPath(alloc) catch return error.SpawnFailed;
120 defer alloc.free(log_path);
121 if (std.fs.path.dirname(log_path)) |dir|
122 std.fs.cwd().makePath(dir) catch return error.SpawnFailed;
123 // Opened by hand rather than through createFile because the non-
124 // truncating case needs O_APPEND specifically, which createFile cannot
125 // ask for. "Do not truncate" alone would be worse than truncating: the
126 // child's fd would start at offset zero and overwrite the log from the
127 // front, and two daemons sharing the path would overwrite each other.
128 // The kernel's atomic seek-to-end is the whole of what makes appending
129 // safe for a file someone else may be writing.
130 const log: std.fs.File = .{
131 .handle = std.posix.open(log_path, .{
132 .ACCMODE = .WRONLY,
133 .CREAT = true,
134 .TRUNC = log_spec.truncate,
135 .APPEND = !log_spec.truncate,
136 // createFile set this for free and posix.O does not, which is
137 // exactly how hand-rolling the open lost it. Without it the
138 // original fd survives the exec below into the long-lived daemon
139 // and rides a second exec into the user's shell; fds 1 and 2 are
140 // safe only because dup2 clears FD_CLOEXEC on its targets.
141 .CLOEXEC = true,
142 }, 0o600) catch return error.SpawnFailed,
143 };
144 defer log.close();
145 const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed;
146 defer devnull.close();
147
148 // argv for the child: mux d start <forwarded...>, all null-terminated.
149 // The mode word is spelled out so `ps` shows a daemon as a daemon —
150 // it is the only thing separating the long-lived process from the
151 // `mux d start -d` that spawned it.
152 const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed;
153 defer alloc.free(exe_z);
154 const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 3, null) catch
155 return error.SpawnFailed;
156 defer alloc.free(argv);
157 argv[0] = "mux";
158 argv[1] = "d";
159 argv[2] = "start";
160 for (run_args, 0..) |a, i| argv[i + 3] = a.ptr;
161
162 progress.emitFmt("{s}: starting\u{2026}", .{progress.prefix});
163 if (!progress.tty) progress.emit("\n");
164
165 const t0 = std.time.milliTimestamp();
166 const pid = std.posix.fork() catch {
167 if (progress.tty) progress.emit("\n");
168 return error.SpawnFailed;
169 };
170 if (pid == 0) {
171 // Child: its own session, no controlling terminal, stdio detached.
172 // Nothing here may allocate or return — only exec or _exit.
173 //
174 // exit_group, never std.posix.exit: we link libc, so the latter is
175 // exit(3), which runs atexit handlers (Zig's runtime, wolfSSL's)
176 // and flushes stdio buffers — buffers this process inherited from
177 // the PARENT at fork, so the parent's pending output would be
178 // written a second time by its own child. exit_group is the raw
179 // syscall and skips all of it. Only reachable if dup2 or exec
180 // fails, which is exactly when the least machinery should run.
181 _ = std.os.linux.setsid();
182 std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch
183 std.os.linux.exit_group(127);
184 std.posix.dup2(log.handle, std.posix.STDOUT_FILENO) catch
185 std.os.linux.exit_group(127);
186 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch
187 std.os.linux.exit_group(127);
188 // An exec rather than simply running the daemon in this fork, and
189 // that is not a preference: `std.debug.MemoryAccessor` caches the
190 // pid it reads memory through, so a forked child's first
191 // DebugAllocator stack trace calls `process_vm_readv` on the
192 // parent, gets ESRCH, and panics on `unreachable // own pid is
193 // always valid`. Measured, deterministic, and invisible until the
194 // daemon has been up long enough to allocate.
195 //
196 // execveZ's return type IS an error set — there is no success value,
197 // because success does not return. 127 is the shell's "cannot exec",
198 // and the parent learns the same thing either way: the socket never
199 // answers, and the log names what happened.
200 switch (std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ)) {
201 else => std.os.linux.exit_group(127),
202 }
203 }
204 last_spawned_pid = pid;
205
206 // Parent: poll. Dots only on a tty so scripted output stays pinnable.
207 var next_dot: i64 = t0 + 250;
208 // A pid owes us exactly one reap. Calling waitpid again after it has
209 // been reaped gets ECHILD, which std.posix.waitpid answers with
210 // `unreachable` — so the second call is not an error to handle but a
211 // panic, and the panic lands precisely on the path that exists to
212 // report a child that died young (a missing key file, a bad bind
213 // address). Tracking the reap is what keeps that path a message.
214 var reaped = false;
215 while (true) {
216 if (probe(sock_path)) {
217 const secs = @as(f64, @floatFromInt(std.time.milliTimestamp() - t0)) / 1000.0;
218 // The leading space exists to follow the dots on a tty. There
219 // are no dots on a non-tty — the ssh proxy's case, and now the
220 // common one — where it would only be a stray space at the
221 // start of a scripted line.
222 if (progress.tty) progress.emit(" ");
223 progress.emitFmt("up ({d:.1}s) pid={d}\n", .{ secs, pid });
224 return .started;
225 }
226 const now = std.time.milliTimestamp();
227 if (now - t0 >= deadline_ms) {
228 // The newline terminates the dot line, so it belongs to the
229 // same condition the dots do: on a non-tty there are no dots
230 // and it would only put a blank line into scripted output.
231 if (progress.tty) progress.emit("\n");
232 // "daemon", not "muxd", in the body: the prefix is the program
233 // speaking, and the same string serves the mux-side callers —
234 // `mux: daemon did not answer` reads correctly, `mux: muxd did
235 // not answer` would not.
236 progress.emitFmt(
237 "{s}: daemon did not answer within {d}s \u{2014} log: {s}\n",
238 .{ progress.prefix, deadline_ms / 1000, log_path },
239 );
240 return error.NeverAnswered;
241 }
242 if (progress.tty and now >= next_dot) {
243 progress.emit(".");
244 next_dot = now + 250;
245 }
246 // Reap if the child exited (loser of a start race, or a refused
247 // flag): its socket-owner sibling answers the next probe either
248 // way, and an unreaped child would sit as a zombie until we exit.
249 // Once is enough, and once is all that is safe — see `reaped`.
250 if (!reaped and std.posix.waitpid(pid, std.posix.W.NOHANG).pid == pid)
251 reaped = true;
252 std.Thread.sleep(50 * std.time.ns_per_ms);
253 }
254 }
255
256 /// False covers both "no daemon" and "a stale socket file": nothing
257 /// answered either way, and the caller's next move is the same.
258 pub fn probe(sock_path: []const u8) bool {
259 const s = std.net.connectUnixSocket(sock_path) catch return false;
260 s.close();
261 return true;
262 }
263
264 /// Both starters, one wrapper: this image, the one deadline, a failure
265 /// already narrated — null needs only an exit code.
266 pub fn ensure(
267 alloc: std.mem.Allocator,
268 run_args: []const [:0]const u8,
269 sock_path: []const u8,
270 prefix: []const u8,
271 log_spec: LogSpec,
272 ) ?Ensured {
273 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
274 const exe = selfExe(&exe_buf);
275 const progress: Progress = .{
276 .fd = std.posix.STDERR_FILENO,
277 .prefix = prefix,
278 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
279 };
280 return ensureDaemon(alloc, exe, run_args, sock_path, progress, start_deadline_ms, log_spec) catch |err| {
281 // NeverAnswered's failure line, with the log path, was already
282 // printed by Progress — a second line would say the same thing
283 // worse. A spawn that never happened has nothing printed yet, and
284 // std.debug.print rather than progress.emitFmt because an exe path
285 // can run to max_path_bytes and emitFmt's fixed buffer would drop
286 // the whole line rather than shorten it. The RESOLVED path, so the
287 // line names a file an operator can stat.
288 if (err == error.SpawnFailed)
289 std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, exe, @errorName(err) });
290 return null;
291 };
292 }
293
294 /// The local entry's auto-start alone, naming no binary: there is one,
295 /// and it is this.
296 pub fn ensureForAttach(
297 alloc: std.mem.Allocator,
298 sock_path: []const u8,
299 prefix: []const u8,
300 ) error{OutOfMemory}!bool {
301 const sock_z = try alloc.dupeZ(u8, sock_path);
302 defer alloc.free(sock_z);
303 // Bare, and the reason is the whole of why this is not `start`: a QUIC
304 // listener must be asked for, never appear because someone attached.
305 const run_args = [_][:0]const u8{ "--sock", sock_z };
306 return ensure(alloc, &run_args, sock_path, prefix, .{ .truncate = false }) != null;
307 }
308 42
309 // --------------------------------------------------------------------------- 43 // ---------------------------------------------------------------------------
310 44
311 const testtmp = @import("testtmp");
312
313 fn silentProgress() Progress {
314 // Progress that writes to /dev/null keeps test output clean while the
315 // pinned-output cases below capture a pipe instead.
316 const f = std.fs.cwd().openFile("/dev/null", .{ .mode = .write_only }) catch unreachable;
317 return .{ .fd = f.handle, .prefix = "test", .tty = false };
318 }
319
320 test "ensureDaemon: an answering socket is already_running, nothing spawned" {
321 var tmp = try testtmp.TmpDir.make();
322 defer tmp.cleanup();
323 var buf: [128]u8 = undefined;
324 const sock = try std.fmt.bufPrint(&buf, "{s}/live.sock", .{tmp.path()});
325
326 const addr = try std.net.Address.initUnix(sock);
327 var server = try addr.listen(.{});
328 defer server.deinit();
329
330 // Progress captured through a pipe: already_running must print NOTHING.
331 const pipe = try std.posix.pipe();
332 defer std.posix.close(pipe[0]);
333 const progress: Progress = .{ .fd = pipe[1], .prefix = "test", .tty = false };
334
335 const r = try ensureDaemon(
336 std.testing.allocator,
337 "/definitely/not/consulted",
338 &.{},
339 sock,
340 progress,
341 200,
342 .{ .truncate = true },
343 );
344 try std.testing.expectEqual(Ensured.already_running, r);
345
346 std.posix.close(pipe[1]);
347 var out: [64]u8 = undefined;
348 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &out));
349 }
350
351 test "ensureDaemon: a missing binary is SpawnFailed before any fork" {
352 var tmp = try testtmp.TmpDir.make();
353 defer tmp.cleanup();
354 var buf: [128]u8 = undefined;
355 const sock = try std.fmt.bufPrint(&buf, "{s}/none.sock", .{tmp.path()});
356 try std.testing.expectError(error.SpawnFailed, ensureDaemon(
357 std.testing.allocator,
358 "/no/such/muxd",
359 &.{},
360 sock,
361 silentProgress(),
362 200,
363 .{ .truncate = true },
364 ));
365 }
366
367 test "selfExe: the exec'd name is a real file, not the /proc link" { 45 test "selfExe: the exec'd name is a real file, not the /proc link" {
368 var buf: [std.fs.max_path_bytes]u8 = undefined; 46 var buf: [std.fs.max_path_bytes]u8 = undefined;
369 const exe = selfExe(&buf); 47 const exe = selfExe(&buf);
@@ -388,217 +66,6 @@ test "selfExe: a resolved path that is no longer a file falls back to the link"
388 try std.testing.expectEqualStrings(self_exe, execOrLink(deleted)); 66 try std.testing.expectEqualStrings(self_exe, execOrLink(deleted));
389 } 67 }
390 68
391 test "ensureDaemon: a child that dies young is reported, not panicked on" {
392 var tmp = try testtmp.TmpDir.make();
393 defer tmp.cleanup();
394 var pbuf: [128]u8 = undefined;
395 var sbuf: [128]u8 = undefined;
396 var lbuf: [128]u8 = undefined;
397 const stub = try std.fmt.bufPrint(&pbuf, "{s}/dies.sh", .{tmp.path()});
398 const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()});
399 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
400
401 // Exits at once, binding nothing — a daemon refusing a flag, or one
402 // whose key file is missing. The poll loop therefore reaps it on an
403 // early pass and keeps polling to the deadline, which is where a
404 // second waitpid would get ECHILD and panic.
405 try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" });
406 const f = try tmp.dir.openFile("dies.sh", .{});
407 try f.chmod(0o755);
408 f.close();
409
410 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
411 std.testing.allocator,
412 stub,
413 &.{},
414 sock,
415 silentProgress(),
416 300,
417 .{ .path = log, .truncate = true },
418 ));
419 // The log is still there to be named by the failure line: a child that
420 // died is exactly when an operator goes looking for it.
421 const log_st = try std.fs.cwd().statFile(log);
422 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
423 }
424
425 test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive" {
426 var tmp = try testtmp.TmpDir.make();
427 defer tmp.cleanup();
428 var pbuf: [128]u8 = undefined;
429 var sbuf: [128]u8 = undefined;
430 var lbuf: [128]u8 = undefined;
431 const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
432 const sock = try std.fmt.bufPrint(&sbuf, "{s}/never.sock", .{tmp.path()});
433 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
434
435 // A stand-in daemon that stays alive and binds nothing. `exec` so the
436 // pid ensureDaemon tracked IS the sleeper, not a parent shell of it.
437 try tmp.dir.writeFile(.{ .sub_path = "stub.sh", .data = "#!/bin/sh\nexec sleep 30\n" });
438 const f = try tmp.dir.openFile("stub.sh", .{});
439 try f.chmod(0o755);
440 f.close();
441
442 // The log goes somewhere disposable. xdg.logPath reads XDG_STATE_HOME
443 // at call time and Zig tests cannot setenv, so leaving this null would
444 // truncate the real `~/.local/state/mux/muxd.log` — which, once `muxd
445 // start` exists, is a LIVE daemon's log being zeroed by `make test`.
446 // The nested `logs/` component also proves the parent directory is
447 // created rather than assumed.
448 const t0 = std.time.milliTimestamp();
449 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
450 std.testing.allocator,
451 stub,
452 &.{},
453 sock,
454 silentProgress(),
455 300,
456 .{ .path = log, .truncate = true },
457 ));
458 // The log is created before the fork, so it exists even when the child
459 // never writes to it — that is what makes it the place to look when a
460 // spawn fails.
461 const log_st = try std.fs.cwd().statFile(log);
462 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
463 // It waited the deadline out rather than bailing early...
464 try std.testing.expect(std.time.milliTimestamp() - t0 >= 300);
465
466 // ...and did NOT kill the spawned process. `last_spawned_pid` is how
467 // the test learns which pid that is: killing by anything else — a name
468 // match, a process sweep — could take out a bystander, so the pid the
469 // spawner tracked is the only handle allowed.
470 try std.testing.expect(last_spawned_pid != 0);
471 // waitpid with NOHANG returning pid 0 means "child exists, still
472 // running", which is the assertion; a reaped or dead child returns its
473 // own pid instead.
474 try std.testing.expectEqual(
475 @as(std.posix.pid_t, 0),
476 std.posix.waitpid(last_spawned_pid, std.posix.W.NOHANG).pid,
477 );
478 std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
479 _ = std.posix.waitpid(last_spawned_pid, 0);
480 }
481
482 test "ensureDaemon: the child execs the path it was HANDED, never a name off PATH" {
483 var tmp = try testtmp.TmpDir.make();
484 defer tmp.cleanup();
485 var pbuf: [160]u8 = undefined;
486 var sbuf: [160]u8 = undefined;
487 var lbuf: [160]u8 = undefined;
488 var rbuf: [160]u8 = undefined;
489 const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
490 const sock = try std.fmt.bufPrint(&sbuf, "{s}/self.sock", .{tmp.path()});
491 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/mux.log", .{tmp.path()});
492 const seen = try std.fmt.bufPrint(&rbuf, "{s}/child.exe", .{tmp.path()});
493
494 // `$0` is the kernel's answer to "which file did you exec": for a
495 // shebang script it is the script, whatever argv[0] the caller wrote.
496 // Nothing on this box resolves by NAME to a file in a fresh tmp dir, so
497 // a spawn that searched PATH cannot pass this. `$*` pins the other half
498 // — the daemon is asked for `d start`, which is also what makes a daemon
499 // legible in `ps`. Production hands `selfExe` and nothing else, so the
500 // same mechanism cannot reach a sibling: `mux` used to hunt PATH for a
501 // `muxd`, and an e2e leg graded an installed v0.0.1-10 that way.
502 var script: [512]u8 = undefined;
503 try tmp.dir.writeFile(.{
504 .sub_path = "stub.sh",
505 .data = try std.fmt.bufPrint(&script,
506 \\#!/bin/sh
507 \\printf '%s|%s' "$0" "$*" > "{s}"
508 \\exec sleep 30
509 \\
510 , .{seen}),
511 });
512 const f = try tmp.dir.openFile("stub.sh", .{});
513 try f.chmod(0o755);
514 f.close();
515
516 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
517 std.testing.allocator,
518 stub,
519 &.{},
520 sock,
521 silentProgress(),
522 400,
523 .{ .path = log, .truncate = true },
524 ));
525 defer {
526 std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
527 _ = std.posix.waitpid(last_spawned_pid, 0);
528 }
529
530 var got_buf: [std.fs.max_path_bytes]u8 = undefined;
531 // Read through the failure rather than around it: a child that never
532 // ran the stub at all must fail as a MISMATCH naming what it became,
533 // not as a FileNotFound three frames inside std.
534 const got = std.fs.cwd().readFile(seen, &got_buf) catch "<the child ran something else>";
535 var want_buf: [200]u8 = undefined;
536 const want = try std.fmt.bufPrint(&want_buf, "{s}|d start", .{stub});
537 try std.testing.expectEqualStrings(want, std.mem.trimRight(u8, got, "\n"));
538 }
539
540 test "ensureDaemon: an attach appends to the log, `mux d start -d` truncates it" {
541 var tmp = try testtmp.TmpDir.make();
542 defer tmp.cleanup();
543 var pbuf: [128]u8 = undefined;
544 var sbuf: [128]u8 = undefined;
545 var lbuf: [128]u8 = undefined;
546 const stub = try std.fmt.bufPrint(&pbuf, "{s}/dies.sh", .{tmp.path()});
547 const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()});
548 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
549
550 // Exits at once and binds nothing, so both spawns below end in
551 // NeverAnswered — which is beside the point. The log is opened BEFORE
552 // the fork, so what happened to the bytes already in it is settled
553 // whether the daemon ever comes up or not, and a stub that writes
554 // nothing keeps the two file contents readable as pure evidence of the
555 // open mode.
556 try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" });
557 const f = try tmp.dir.openFile("dies.sh", .{});
558 try f.chmod(0o755);
559 f.close();
560
561 const seed = "a live daemon was writing here\n";
562 try std.fs.cwd().makePath(std.fs.path.dirname(log).?);
563 try std.fs.cwd().writeFile(.{ .sub_path = log, .data = seed });
564
565 // The attach shape must leave it alone. Without this, one `mux --sock`
566 // naming a path nobody happens to be serving zeroes the log of the
567 // interactive daemon still writing to it, and the operator reads a
568 // hole where the crash was.
569 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
570 std.testing.allocator,
571 stub,
572 &.{},
573 sock,
574 silentProgress(),
575 100,
576 .{ .path = log, .truncate = false },
577 ));
578 var rbuf: [256]u8 = undefined;
579 // Equality, not "contains": appending must add at the END, so a mode
580 // that wrote over the front and happened to be shorter still fails.
581 try std.testing.expectEqualStrings(seed, try std.fs.cwd().readFile(log, &rbuf));
582
583 // `mux d start -d` still truncates, and that half is asserted here rather
584 // than assumed: the two verbs differ only in this flag, so a change that
585 // made everything append would otherwise pass unnoticed until an
586 // operator read a restarted daemon's log and found the old one's.
587 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
588 std.testing.allocator,
589 stub,
590 &.{},
591 sock,
592 silentProgress(),
593 100,
594 .{ .path = log, .truncate = true },
595 ));
596 try std.testing.expectEqual(
597 @as(usize, 0),
598 (try std.fs.cwd().readFile(log, &rbuf)).len,
599 );
600 }
601
602 // Forces semantic analysis of every pub decl under `zig build test`, so an 69 // Forces semantic analysis of every pub decl under `zig build test`, so an
603 // unreferenced decl must at least compile (the silent-module-loss hazard, 70 // unreferenced decl must at least compile (the silent-module-loss hazard,
604 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 71 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
src/sockpath.zig
Old New
@@ -87,6 +87,22 @@ pub const PathId = struct {
87 } 87 }
88 }; 88 };
89 89
90 /// Whether anything is LISTENING at `path` right now — the one connect
91 /// probe, for `claim` and for everyone who only wants the answer.
92 pub fn answers(path: []const u8) bool {
93 if (std.net.connectUnixSocket(path)) |s| {
94 s.close();
95 return true;
96 } else |err| return switch (err) {
97 // Two errors are a no; every other one is a yes. A path whose
98 // connect failed for some third reason may still be a live
99 // daemon's, and a false here is a licence to unlink it — the field
100 // incident `claim` below exists for.
101 error.FileNotFound, error.ConnectionRefused => false,
102 else => true,
103 };
104 }
105
90 /// Make the socket path ours to bind, or refuse it. Field incident this 106 /// Make the socket path ours to bind, or refuse it. Field incident this
91 /// exists for: three daemons were started against one path, each 107 /// exists for: three daemons were started against one path, each
92 /// unlinking it and binding fresh. Every one of them kept running with 108 /// unlinking it and binding fresh. Every one of them kept running with
@@ -104,20 +120,13 @@ pub const PathId = struct {
104 /// identify as a dead daemon's leftover is not 120 /// identify as a dead daemon's leftover is not
105 /// something we may delete. 121 /// something we may delete.
106 pub fn claim(path: []const u8) !void { 122 pub fn claim(path: []const u8) !void {
107 if (std.net.connectUnixSocket(path)) |probe| { 123 if (answers(path)) return error.DaemonAlreadyRunning;
108 probe.close();
109 return error.DaemonAlreadyRunning;
110 } else |err| switch (err) {
111 error.FileNotFound => return, // free path; bind straight away
112 // Nobody is listening — but this is NOT yet proof of a stale
113 // socket: Linux answers ECONNREFUSED for a regular file at the
114 // path exactly as it does for a dead socket, so connect alone
115 // cannot tell a dead daemon from `mux d start --sock notes.txt`.
116 // The stat below is what separates them.
117 error.ConnectionRefused => {},
118 else => |e| return e,
119 }
120 124
125 // Nothing answered — which is NOT yet proof of a stale socket: Linux
126 // answers ECONNREFUSED for a regular file at the path exactly as it
127 // does for a dead socket, so the connect alone cannot tell a dead
128 // daemon from `mux d start --sock notes.txt`. The stat is what
129 // separates them.
121 const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) { 130 const st = std.posix.fstatat(std.posix.AT.FDCWD, path, 0) catch |err| switch (err) {
122 error.FileNotFound => return, // vanished under us; path is free 131 error.FileNotFound => return, // vanished under us; path is free
123 else => |e| return e, 132 else => |e| return e,
@@ -143,6 +152,30 @@ test "default path: an unset XDG_RUNTIME_DIR is refused, never guessed" {
143 try std.testing.expectEqualStrings("/run/user/1000/muxd.sock", found); 152 try std.testing.expectEqualStrings("/run/user/1000/muxd.sock", found);
144 } 153 }
145 154
155 test "answers: a live listener, a stale socket file, and a path with nothing on it" {
156 const testtmp = @import("testtmp");
157 var tmp = try testtmp.TmpDir.make();
158 defer tmp.cleanup();
159
160 var buf: [64]u8 = undefined;
161 const path = try std.fmt.bufPrint(&buf, "{s}/a.sock", .{tmp.path()});
162
163 // Nothing there at all: the common case, and the one every caller acts
164 // on by starting a daemon.
165 try std.testing.expect(!answers(path));
166
167 const addr = try std.net.Address.initUnix(path);
168 var listener = try addr.listen(.{});
169 try std.testing.expect(answers(path));
170
171 // The socket FILE outliving its daemon is not an answer. `claim` reads
172 // this as "mine to clear"; a true here would refuse every start after
173 // a daemon that died without unlinking.
174 listener.deinit();
175 try std.testing.expect(!answers(path));
176 try std.fs.cwd().access(path, .{});
177 }
178
146 test "PathId: names the file it was taken from, not the path, and not a successor" { 179 test "PathId: names the file it was taken from, not the path, and not a successor" {
147 const testtmp = @import("testtmp"); 180 const testtmp = @import("testtmp");
148 var tmp = try testtmp.TmpDir.make(); 181 var tmp = try testtmp.TmpDir.make();
test/agent.sh
Old New
@@ -293,14 +293,14 @@ leakcheck() {
293 # name match can only ever name a bystander. 293 # name match can only ever name a bystander.
294 # 294 #
295 # $_log catches what `mux d start -d` ITSELF prints (the up-line, or the refusal). 295 # $_log catches what `mux d start -d` ITSELF prints (the up-line, or the refusal).
296 # The daemon's own output goes somewhere else entirely: start detaches the 296 # The daemon's own output goes somewhere else entirely: `-d` detaches the
297 # child onto $XDG_STATE_HOME/mux/muxd.log — a path computed from the 297 # child onto $XDG_STATE_HOME/mux/muxd.log — a path computed from the
298 # environment `mux d start -d` is run with — and TRUNCATES it at every spawn. One 298 # environment `mux d start -d` is run with — and APPENDS to it. One state
299 # state home for the whole suite would therefore leave exactly one daemon's 299 # home for the whole suite would therefore interleave every daemon's
300 # allocator verdict readable, the last one's, with every earlier verdict 300 # allocator verdict into one file, and a leak found there would name no
301 # overwritten by the next scenario's spawn. So each daemon gets a state home 301 # scenario. So each daemon gets a state home of its own, tagged by its
302 # of its own, tagged by its log's name, and every daemon's verdict survives 302 # log's name, and every verdict is attributable at leakcheck and at the
303 # to leakcheck and to the sweep at the bottom of this file. 303 # sweep at the bottom of this file.
304 start_daemon() { 304 start_daemon() {
305 _log="$1" 305 _log="$1"
306 shift 306 shift
test/e2e_01_boot.sh
Old New
@@ -864,18 +864,27 @@ grep -q "already running on $SOCK8 (stop it first with \`mux d stop --sock $SOCK
864 "$MUX" d dump --sock "$SOCK8" | grep -q "start-works" || { 864 "$MUX" d dump --sock "$SOCK8" | grep -q "start-works" || {
865 echo "e2e FAIL: rerun replaced the daemon (marker gone)"; exit 1; } 865 echo "e2e FAIL: rerun replaced the daemon (marker gone)"; exit 1; }
866 866
867 # Truncated per spawn, not appended to: the log holds the CURRENT daemon's 867 # APPENDED per spawn, never truncated. One xdg log serves every socket on
868 # output, so a stale one must not be what an operator reads after a 868 # the box, and $SOCK8's daemon is still writing to this file: a spawn on
869 # restart. Padded, then a real spawn on a fresh path must shrink it. 869 # the SECOND path that zeroed it would delete a live daemon's crash out
870 dd if=/dev/zero bs=1024 count=8 2>/dev/null >> "$MUXLOG" 870 # from under the operator who went looking for it. Seeded with a line the
871 PADDED=$(stat -c %s "$MUXLOG") 871 # first daemon could have written, then a real spawn on a fresh path must
872 # leave that line where it is and grow the file.
873 SEED="a live daemon was writing here"
874 echo "$SEED" >> "$MUXLOG"
875 SEEDED=$(stat -c %s "$MUXLOG")
872 "$MUX" d start -d --sock "$SOCK8T" 2> "$OUT.start" 876 "$MUX" d start -d --sock "$SOCK8T" 2> "$OUT.start"
873 TPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start") 877 TPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start")
874 defer_kill "$TPID" 878 defer_kill "$TPID"
875 [ -n "$TPID" ] || { echo "e2e FAIL: truncation spawn reported no pid"; exit 1; } 879 [ -n "$TPID" ] || { echo "e2e FAIL: append spawn reported no pid"; exit 1; }
876 SHRUNK=$(stat -c %s "$MUXLOG") 880 GREW=$(stat -c %s "$MUXLOG")
877 [ "$SHRUNK" -lt "$PADDED" ] || { 881 [ "$GREW" -ge "$SEEDED" ] || {
878 echo "e2e FAIL: log not truncated on spawn ($PADDED -> $SHRUNK)"; exit 1; } 882 echo "e2e FAIL: log shrank on spawn ($SEEDED -> $GREW) — a second daemon zeroed the first's"
883 exit 1; }
884 # The bytes themselves, not just the size: a mode that wrote over the front
885 # and happened to be longer would pass a size check alone.
886 grep -qxF "$SEED" "$MUXLOG" || {
887 echo "e2e FAIL: the spawn overwrote what was already in $MUXLOG"; exit 1; }
879 softkill "$TPID" || true 888 softkill "$TPID" || true
880 TPID="" 889 TPID=""
881 890
@@ -924,7 +933,7 @@ assert_converged "$OUT.race" "$SOCK8" "start race"
924 933
925 softkill "$SPID" || true 934 softkill "$SPID" || true
926 SPID="" 935 SPID=""
927 ok "mux d start -d — spawn, no-op rerun, log truncation, race" 936 ok "mux d start -d — spawn, no-op rerun, log append, race"
928 937
929 # --- `mux d endpoint --start` ensures the daemon ON THE SOCKET IT PROBES. 938 # --- `mux d endpoint --start` ensures the daemon ON THE SOCKET IT PROBES.
930 # 939 #
test/e2e_03_side.sh
Old New
@@ -176,8 +176,12 @@ set -e
176 # The fixture hands the client a FILE for stderr, so the progress lines 176 # The fixture hands the client a FILE for stderr, so the progress lines
177 # are non-tty ones and land here rather than on the pty — which is also 177 # are non-tty ones and land here rather than on the pty — which is also
178 # why the capture the convergence check reads is undisturbed by them. 178 # why the capture the convergence check reads is undisturbed by them.
179 grep -q '^mux: starting' "$OUT.pa.err" || { 179 # `mux d`, not `mux`: the client no longer forks anything. It runs
180 echo "e2e FAIL: local auto-start printed no mux-prefixed starting line" 180 # `mux d start -d` with its own fd 2 lent to it, so the words on this file
181 # are the DAEMON's, in the daemon's own voice, and a refusal the client
182 # could not have worded reaches the user unchanged.
183 grep -q '^mux d: starting' "$OUT.pa.err" || {
184 echo "e2e FAIL: local auto-start printed no 'mux d'-prefixed starting line"
181 cat "$OUT.pa.err"; exit 1; } 185 cat "$OUT.pa.err"; exit 1; }
182 PAPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.pa.err" | head -1) 186 PAPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.pa.err" | head -1)
183 defer_kill "$PAPID" 187 defer_kill "$PAPID"
@@ -207,6 +211,19 @@ PACOMM=$(cat "/proc/$PAPID/comm")
207 echo "e2e FAIL: the auto-started daemon's comm is '$PACOMM', want 'mux'" 211 echo "e2e FAIL: the auto-started daemon's comm is '$PACOMM', want 'mux'"
208 echo " (exec the RESOLVED path; the /proc link names the process after itself)" 212 echo " (exec the RESOLVED path; the /proc link names the process after itself)"
209 exit 1; } 213 exit 1; }
214 # And the argv, asked of the kernel for the same reason: the attach ASKS
215 # for a daemon in words — `d start -d --sock PATH`, nothing else — and the
216 # words are all it may say. Anything extra here is the client having an
217 # opinion about the daemon's flags: a `--quic` nobody typed, a `--shell`
218 # read out of the client's own environment. Exactly, not `grep`, because
219 # what this is watching for is an ADDITION. `-d` is absent by the same
220 # rule the fork obeys — the child is the foreground daemon, and a `-d`
221 # still in its line would fork again.
222 PACMD=$(tr '\0' ' ' < "/proc/$PAPID/cmdline")
223 [ "$PACMD" = "mux d start --sock $SOCK15 " ] || {
224 echo "e2e FAIL: the auto-started daemon's argv is [$PACMD],"
225 echo " want [mux d start --sock $SOCK15 ]"
226 exit 1; }
210 assert_converged "$OUT.pa" "$SOCK15" "local mux auto-start" 227 assert_converged "$OUT.pa" "$SOCK15" "local mux auto-start"
211 # Same teardown, same reasons — and the stderr is captured rather than 228 # Same teardown, same reasons — and the stderr is captured rather than
212 # discarded, so this leg pins the stopped line too. $OUT.stop is reused 229 # discarded, so this leg pins the stopped line too. $OUT.stop is reused
test/e2e_lib.sh
Old New
@@ -1034,8 +1034,8 @@ leak_sweep() {
1034 # spells it. One that skipped the register would fail the residue guard in 1034 # spells it. One that skipped the register would fail the residue guard in
1035 # cleanup instead, so there is no third place for a home to hide. 1035 # cleanup instead, so there is no third place for a home to hide.
1036 # 1036 #
1037 # The file is truncated at every spawn, so each home asserts the LAST 1037 # The file is APPENDED to at every spawn, so one home holds every
1038 # daemon started under it — stated, not hidden. 1038 # daemon started under it and the grep below sees all of them.
1039 _lsoifs=$IFS 1039 _lsoifs=$IFS
1040 IFS=' 1040 IFS='
1041 ' 1041 '