fe5397f2
refactor: the daemon starts itself, and every other starter asks in words
a73x 2026-08-30 07:58
Commit message
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 | ' |