d6452d03
refactor: spawn.ensureForAttach owns the attach shape; auto-start appends, never truncates
a73x 2026-08-10 17:53
Commit message
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -246,31 +246,15 @@ pub fn main() !u8 { | |||
| 246 | .stop => return stopCmd(alloc, sock_path), | 246 | .stop => return stopCmd(alloc, sock_path), |
| 247 | .proxy => { | 247 | .proxy => { |
| 248 | // Attach auto-start (M13): the user asked for a session, not a | 248 | // Attach auto-start (M13): the user asked for a session, not a |
| 249 | // daemon. Bare `run` on purpose — a QUIC listener must be asked | 249 | // daemon. Same helper and deadline as `muxd start`. Unlike it, a |
| 250 | // for, never appear because someone attached. Same helper, | 250 | // daemon that was already there is silent: the user asked for a |
| 251 | // deadline, and silence contract as `muxd start`. | 251 | // session and is about to get one. |
| 252 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | 252 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; |
| 253 | const exe = std.fs.selfExePath(&exe_buf) catch { | 253 | const exe = std.fs.selfExePath(&exe_buf) catch { |
| 254 | std.debug.print("muxd proxy: cannot find own binary via /proc/self/exe\n", .{}); | 254 | std.debug.print("muxd proxy: cannot find own binary via /proc/self/exe\n", .{}); |
| 255 | return 1; | 255 | return 1; |
| 256 | }; | 256 | }; |
| 257 | const sock_z = try alloc.dupeZ(u8, sock_path); | 257 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd proxy")) return 1; |
| 258 | defer alloc.free(sock_z); | ||
| 259 | const run_args = [_][:0]const u8{ "--sock", sock_z }; | ||
| 260 | const progress: spawn.Progress = .{ | ||
| 261 | .fd = std.posix.STDERR_FILENO, | ||
| 262 | .prefix = "muxd proxy", | ||
| 263 | .tty = std.posix.isatty(std.posix.STDERR_FILENO), | ||
| 264 | }; | ||
| 265 | _ = spawn.ensureDaemon(alloc, exe, &run_args, sock_path, progress, 2000, null) catch |err| switch (err) { | ||
| 266 | // The failure line, with the log path, was already printed | ||
| 267 | // by Progress — a second line would say the same thing worse. | ||
| 268 | error.NeverAnswered => return 1, | ||
| 269 | error.BinaryNotFound, error.SpawnFailed => { | ||
| 270 | std.debug.print("muxd proxy: could not spawn {s}: {s}\n", .{ exe, @errorName(err) }); | ||
| 271 | return 1; | ||
| 272 | }, | ||
| 273 | }; | ||
| 274 | return proxy.run(sock_path); | 258 | return proxy.run(sock_path); |
| 275 | }, | 259 | }, |
| 276 | } | 260 | } |
| @@ -532,8 +516,13 @@ fn startCmd(alloc: std.mem.Allocator, sock_path: []const u8, forwarded: []const | |||
| 532 | .prefix = "muxd", | 516 | .prefix = "muxd", |
| 533 | .tty = std.posix.isatty(std.posix.STDERR_FILENO), | 517 | .tty = std.posix.isatty(std.posix.STDERR_FILENO), |
| 534 | }; | 518 | }; |
| 535 | // null log path: the xdg default is the whole point for a real daemon. | 519 | // Default log path: the xdg one is the whole point for a real daemon. |
| 536 | const r = spawn.ensureDaemon(alloc, exe, forwarded, sock_path, progress, 2000, null) catch |err| switch (err) { | 520 | // Truncating, and this is the only caller that does: `start` is the one |
| 521 | // verb whose user asked for a (re)start, so the log they go on to read | ||
| 522 | // must be about the daemon they just started. | ||
| 523 | const r = spawn.ensureDaemon(alloc, exe, forwarded, sock_path, progress, spawn.start_deadline_ms, .{ | ||
| 524 | .truncate = true, | ||
| 525 | }) catch |err| switch (err) { | ||
| 537 | // The failure line, with the log path, was already printed by | 526 | // The failure line, with the log path, was already printed by |
| 538 | // Progress — a second line here would say the same thing worse. | 527 | // Progress — a second line here would say the same thing worse. |
| 539 | error.NeverAnswered => return 1, | 528 | error.NeverAnswered => return 1, |
src/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -190,8 +190,7 @@ pub fn main() !u8 { | |||
| 190 | 190 | ||
| 191 | // Attach auto-start (M13): give the attach a daemon to land on. | 191 | // Attach auto-start (M13): give the attach a daemon to land on. |
| 192 | // Unix-socket transport only — quic:// has nothing local to | 192 | // Unix-socket transport only — quic:// has nothing local to |
| 193 | // spawn, and --via's auto-starter is the remote proxy. Bare | 193 | // spawn, and --via's auto-starter is the remote proxy. |
| 194 | // `run`: a listener must be asked for, never be a side effect. | ||
| 195 | const muxd_path = try spawn.findInPath( | 194 | const muxd_path = try spawn.findInPath( |
| 196 | alloc, | 195 | alloc, |
| 197 | std.posix.getenv("PATH") orelse "", | 196 | std.posix.getenv("PATH") orelse "", |
| @@ -199,22 +198,7 @@ pub fn main() !u8 { | |||
| 199 | ); | 198 | ); |
| 200 | defer if (muxd_path) |p| alloc.free(p); | 199 | defer if (muxd_path) |p| alloc.free(p); |
| 201 | if (muxd_path) |exe| { | 200 | if (muxd_path) |exe| { |
| 202 | const sock_z = try alloc.dupeZ(u8, sock_path); | 201 | if (!try spawn.ensureForAttach(alloc, exe, sock_path, "mux")) return 1; |
| 203 | defer alloc.free(sock_z); | ||
| 204 | const run_args = [_][:0]const u8{ "--sock", sock_z }; | ||
| 205 | const progress: spawn.Progress = .{ | ||
| 206 | .fd = std.posix.STDERR_FILENO, | ||
| 207 | .prefix = "mux", | ||
| 208 | .tty = std.posix.isatty(std.posix.STDERR_FILENO), | ||
| 209 | }; | ||
| 210 | _ = spawn.ensureDaemon(alloc, exe, &run_args, sock_path, progress, 2000, null) catch |err| switch (err) { | ||
| 211 | // ensureDaemon's own failure line named the log. | ||
| 212 | error.NeverAnswered => return 1, | ||
| 213 | error.BinaryNotFound, error.SpawnFailed => { | ||
| 214 | std.debug.print("mux: could not spawn {s}: {s}\n", .{ exe, @errorName(err) }); | ||
| 215 | return 1; | ||
| 216 | }, | ||
| 217 | }; | ||
| 218 | } else if (!spawn.probe(sock_path)) { | 202 | } else if (!spawn.probe(sock_path)) { |
| 219 | // No muxd anywhere AND nothing serving: only now is the | 203 | // No muxd anywhere AND nothing serving: only now is the |
| 220 | // missing binary the user's problem, and both facts fit in | 204 | // missing binary the user's problem, and both facts fit in |
src/spawn.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,21 +1,48 @@ | |||
| 1 | //! Get a daemon onto a socket path: probe, spawn detached, poll until it | 1 | //! Get a daemon onto a socket path: probe, spawn detached, poll until it |
| 2 | //! answers. `muxd start` is the explicit caller today; attach auto-start | 2 | //! answers. Three callers: `muxd start`, which is explicit and spelled |
| 3 | //! (banked) becomes a second call site, not a rewrite. | 3 | //! out, and the two attach auto-starts (`muxd proxy` and `mux`), which |
| 4 | //! share `ensureForAttach` below. The M10 prediction held — auto-start | ||
| 5 | //! arrived as call sites, not as a rewrite. | ||
| 4 | const std = @import("std"); | 6 | const std = @import("std"); |
| 5 | const xdg = @import("xdg"); | 7 | const xdg = @import("xdg"); |
| 6 | 8 | ||
| 7 | pub const EnsureError = error{ BinaryNotFound, SpawnFailed, NeverAnswered }; | 9 | pub const EnsureError = error{ BinaryNotFound, SpawnFailed, NeverAnswered }; |
| 8 | pub const Ensured = enum { already_running, started }; | 10 | pub const Ensured = enum { already_running, started }; |
| 9 | 11 | ||
| 12 | /// How long a spawn gets to answer, for every caller. One number because a | ||
| 13 | /// user who waited two seconds for `muxd start` must not wait a different | ||
| 14 | /// two seconds for an attach that starts the same daemon the same way: the | ||
| 15 | /// deadline describes how long muxd takes to bind, which is not a fact | ||
| 16 | /// about which verb asked for it. | ||
| 17 | pub const start_deadline_ms: u32 = 2000; | ||
| 18 | |||
| 19 | /// Where a spawned daemon's stdout+stderr go, and what becomes of whatever | ||
| 20 | /// is already there. `truncate` has no default on purpose — the callers | ||
| 21 | /// want opposite answers, so each one has to say which it means. | ||
| 22 | pub const LogSpec = struct { | ||
| 23 | /// null means the xdg default, which is what every real caller wants. | ||
| 24 | /// It is settable at all so tests can point it somewhere disposable: | ||
| 25 | /// the default path belongs to whatever daemon is running on the | ||
| 26 | /// machine, and writing over it from a unit test would punch a hole in | ||
| 27 | /// a live daemon's log. | ||
| 28 | path: ?[]const u8 = null, | ||
| 29 | /// True only for `muxd start`, the one caller whose user asked for a | ||
| 30 | /// (re)start and is owed a log about the daemon they just started | ||
| 31 | /// rather than a stale one. Auto-start appends instead: an attach is | ||
| 32 | /// not a restart, and one `mux` over ssh must not zero the log of an | ||
| 33 | /// interactive daemon that is still writing into it. | ||
| 34 | truncate: bool, | ||
| 35 | }; | ||
| 36 | |||
| 10 | /// Test hook: the pid of the most recent spawn. Tests use it to reap the | 37 | /// Test hook: the pid of the most recent spawn. Tests use it to reap the |
| 11 | /// deliberately-orphaned stub; muxd start reads it for the up-line. Not | 38 | /// deliberately-orphaned stub; muxd start reads it for the up-line. Not |
| 12 | /// synchronized — single-threaded callers only, which both callers are. | 39 | /// synchronized — single-threaded callers only, which all three are. |
| 13 | pub var last_spawned_pid: std.posix.pid_t = 0; | 40 | pub var last_spawned_pid: std.posix.pid_t = 0; |
| 14 | 41 | ||
| 15 | /// All stderr output belongs to this struct: the caller decides the prefix | 42 | /// All of ensureDaemon's stderr output belongs to this struct: the caller |
| 16 | /// ("muxd" today, "mux" when auto-start lands) and whether dots animate. | 43 | /// decides the prefix ("muxd", "muxd proxy", "mux") and whether dots |
| 17 | /// Silence on the already-running path is part of the contract — any | 44 | /// animate. Silence on the already-running path is part of the contract — |
| 18 | /// output at all means something unusual happened. | 45 | /// any output at all means something unusual happened. |
| 19 | pub const Progress = struct { | 46 | pub const Progress = struct { |
| 20 | fd: std.posix.fd_t, | 47 | fd: std.posix.fd_t, |
| 21 | prefix: []const u8, | 48 | prefix: []const u8, |
| @@ -33,8 +60,9 @@ pub const Progress = struct { | |||
| 33 | }; | 60 | }; |
| 34 | 61 | ||
| 35 | /// Probe `sock_path`; if nothing answers, exec `exe_path run <run_args...>` | 62 | /// Probe `sock_path`; if nothing answers, exec `exe_path run <run_args...>` |
| 36 | /// detached (setsid, stdin /dev/null, stdout+stderr truncating the xdg log) | 63 | /// detached (setsid, stdin /dev/null, stdout+stderr to the log `log_spec` |
| 37 | /// and poll every 50ms until the socket accepts or `deadline_ms` passes. | 64 | /// names) and poll every 50ms until the socket accepts or `deadline_ms` |
| 65 | /// passes. | ||
| 38 | /// | 66 | /// |
| 39 | /// On `NeverAnswered` the spawned pid is deliberately NOT killed: a daemon | 67 | /// On `NeverAnswered` the spawned pid is deliberately NOT killed: a daemon |
| 40 | /// that comes up at 2.5s should be there for the retry, not murdered for | 68 | /// that comes up at 2.5s should be there for the retry, not murdered for |
| @@ -43,11 +71,6 @@ pub const Progress = struct { | |||
| 43 | /// Two racers both spawning is handled by the daemon itself: the loser | 71 | /// Two racers both spawning is handled by the daemon itself: the loser |
| 44 | /// exits on DaemonAlreadyRunning (server.zig claimSockPath) and the | 72 | /// exits on DaemonAlreadyRunning (server.zig claimSockPath) and the |
| 45 | /// loser's poll connects to the winner. | 73 | /// loser's poll connects to the winner. |
| 46 | /// `log_path` names where the child's stdout+stderr go; null means the xdg | ||
| 47 | /// default, which is what every real caller wants. It is a parameter at all | ||
| 48 | /// so tests can point it somewhere disposable: the default path belongs to | ||
| 49 | /// whatever daemon is running on the machine, and truncating it from a unit | ||
| 50 | /// test would punch a hole in a live daemon's log. | ||
| 51 | pub fn ensureDaemon( | 74 | pub fn ensureDaemon( |
| 52 | alloc: std.mem.Allocator, | 75 | alloc: std.mem.Allocator, |
| 53 | exe_path: []const u8, | 76 | exe_path: []const u8, |
| @@ -55,7 +78,7 @@ pub fn ensureDaemon( | |||
| 55 | sock_path: []const u8, | 78 | sock_path: []const u8, |
| 56 | progress: Progress, | 79 | progress: Progress, |
| 57 | deadline_ms: u32, | 80 | deadline_ms: u32, |
| 58 | log_path_opt: ?[]const u8, | 81 | log_spec: LogSpec, |
| 59 | ) EnsureError!Ensured { | 82 | ) EnsureError!Ensured { |
| 60 | if (probe(sock_path)) return .already_running; | 83 | if (probe(sock_path)) return .already_running; |
| 61 | 84 | ||
| @@ -63,15 +86,26 @@ pub fn ensureDaemon( | |||
| 63 | 86 | ||
| 64 | // Owned either way, so one `free` covers both: a caller-supplied path is | 87 | // Owned either way, so one `free` covers both: a caller-supplied path is |
| 65 | // duped rather than borrowed, because the xdg branch must allocate. | 88 | // duped rather than borrowed, because the xdg branch must allocate. |
| 66 | const log_path = if (log_path_opt) |p| | 89 | const log_path = if (log_spec.path) |p| |
| 67 | alloc.dupe(u8, p) catch return error.SpawnFailed | 90 | alloc.dupe(u8, p) catch return error.SpawnFailed |
| 68 | else | 91 | else |
| 69 | xdg.logPath(alloc) catch return error.SpawnFailed; | 92 | xdg.logPath(alloc) catch return error.SpawnFailed; |
| 70 | defer alloc.free(log_path); | 93 | defer alloc.free(log_path); |
| 71 | if (std.fs.path.dirname(log_path)) |dir| | 94 | if (std.fs.path.dirname(log_path)) |dir| |
| 72 | std.fs.cwd().makePath(dir) catch return error.SpawnFailed; | 95 | std.fs.cwd().makePath(dir) catch return error.SpawnFailed; |
| 73 | const log = std.fs.cwd().createFile(log_path, .{ .truncate = true, .mode = 0o600 }) catch | 96 | // Opened by hand rather than through createFile because the non- |
| 74 | return error.SpawnFailed; | 97 | // truncating case needs O_APPEND specifically, which createFile cannot |
| 98 | // ask for. "Do not truncate" alone would be worse than truncating: the | ||
| 99 | // child's fd would start at offset zero and overwrite the log from the | ||
| 100 | // front, and two daemons sharing the path would overwrite each other. | ||
| 101 | // The kernel's atomic seek-to-end is the whole of what makes appending | ||
| 102 | // safe for a file someone else may be writing. | ||
| 103 | const log: std.fs.File = .{ .handle = std.posix.open(log_path, .{ | ||
| 104 | .ACCMODE = .WRONLY, | ||
| 105 | .CREAT = true, | ||
| 106 | .TRUNC = log_spec.truncate, | ||
| 107 | .APPEND = !log_spec.truncate, | ||
| 108 | }, 0o600) catch return error.SpawnFailed }; | ||
| 75 | defer log.close(); | 109 | defer log.close(); |
| 76 | const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed; | 110 | const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed; |
| 77 | defer devnull.close(); | 111 | defer devnull.close(); |
| @@ -134,7 +168,12 @@ pub fn ensureDaemon( | |||
| 134 | while (true) { | 168 | while (true) { |
| 135 | if (probe(sock_path)) { | 169 | if (probe(sock_path)) { |
| 136 | const secs = @as(f64, @floatFromInt(std.time.milliTimestamp() - t0)) / 1000.0; | 170 | const secs = @as(f64, @floatFromInt(std.time.milliTimestamp() - t0)) / 1000.0; |
| 137 | progress.emitFmt(" up ({d:.1}s) pid={d}\n", .{ secs, pid }); | 171 | // The leading space exists to follow the dots on a tty. There |
| 172 | // are no dots on a non-tty — the ssh proxy's case, and now the | ||
| 173 | // common one — where it would only be a stray space at the | ||
| 174 | // start of a scripted line. | ||
| 175 | if (progress.tty) progress.emit(" "); | ||
| 176 | progress.emitFmt("up ({d:.1}s) pid={d}\n", .{ secs, pid }); | ||
| 138 | return .started; | 177 | return .started; |
| 139 | } | 178 | } |
| 140 | const now = std.time.milliTimestamp(); | 179 | const now = std.time.milliTimestamp(); |
| @@ -144,9 +183,9 @@ pub fn ensureDaemon( | |||
| 144 | // and it would only put a blank line into scripted output. | 183 | // and it would only put a blank line into scripted output. |
| 145 | if (progress.tty) progress.emit("\n"); | 184 | if (progress.tty) progress.emit("\n"); |
| 146 | // "daemon", not "muxd", in the body: the prefix is the program | 185 | // "daemon", not "muxd", in the body: the prefix is the program |
| 147 | // speaking, and this same string serves the mux-side caller | 186 | // speaking, and the same string serves the mux-side callers — |
| 148 | // when auto-start lands — `mux: daemon did not answer` reads | 187 | // `mux: daemon did not answer` reads correctly, `mux: muxd did |
| 149 | // correctly, `mux: muxd did not answer` would not. | 188 | // not answer` would not. |
| 150 | progress.emitFmt( | 189 | progress.emitFmt( |
| 151 | "{s}: daemon did not answer within {d}s \u{2014} log: {s}\n", | 190 | "{s}: daemon did not answer within {d}s \u{2014} log: {s}\n", |
| 152 | .{ progress.prefix, deadline_ms / 1000, log_path }, | 191 | .{ progress.prefix, deadline_ms / 1000, log_path }, |
| @@ -176,6 +215,60 @@ pub fn probe(sock_path: []const u8) bool { | |||
| 176 | return true; | 215 | return true; |
| 177 | } | 216 | } |
| 178 | 217 | ||
| 218 | /// The attach shape, owned in one place: both auto-start call sites (`muxd | ||
| 219 | /// proxy` and `mux`) want exactly this — a BARE `run --sock <path>`, the | ||
| 220 | /// shared deadline, stderr progress under the caller's own prefix, and a | ||
| 221 | /// log that is appended to rather than truncated. | ||
| 222 | /// | ||
| 223 | /// True means there is a daemon to attach to, started or already up. False | ||
| 224 | /// means give up: the reason is already on stderr, so a caller's `return 1` | ||
| 225 | /// needs no message of its own. | ||
| 226 | /// | ||
| 227 | /// `muxd start` deliberately does NOT go through here. It forwards the | ||
| 228 | /// user's own flags rather than a fixed pair, it truncates the log, and it | ||
| 229 | /// REPORTS already-running where this stays silent — because only one of | ||
| 230 | /// the two was asked for. Someone who typed `muxd start` asked about a | ||
| 231 | /// daemon and is owed a verdict on one; someone who typed `mux` asked for a | ||
| 232 | /// session and is about to get it. | ||
| 233 | pub fn ensureForAttach( | ||
| 234 | alloc: std.mem.Allocator, | ||
| 235 | exe: []const u8, | ||
| 236 | sock_path: []const u8, | ||
| 237 | prefix: []const u8, | ||
| 238 | ) error{OutOfMemory}!bool { | ||
| 239 | const sock_z = try alloc.dupeZ(u8, sock_path); | ||
| 240 | defer alloc.free(sock_z); | ||
| 241 | // Bare, and the reason is the whole of why this is not `start`: a QUIC | ||
| 242 | // listener must be asked for, never appear because someone attached. | ||
| 243 | const run_args = [_][:0]const u8{ "--sock", sock_z }; | ||
| 244 | const progress: Progress = .{ | ||
| 245 | .fd = std.posix.STDERR_FILENO, | ||
| 246 | .prefix = prefix, | ||
| 247 | .tty = std.posix.isatty(std.posix.STDERR_FILENO), | ||
| 248 | }; | ||
| 249 | _ = ensureDaemon( | ||
| 250 | alloc, | ||
| 251 | exe, | ||
| 252 | &run_args, | ||
| 253 | sock_path, | ||
| 254 | progress, | ||
| 255 | start_deadline_ms, | ||
| 256 | .{ .truncate = false }, | ||
| 257 | ) catch |err| switch (err) { | ||
| 258 | // The failure line, with the log path, was already printed by | ||
| 259 | // Progress — a second line would say the same thing worse. | ||
| 260 | error.NeverAnswered => return false, | ||
| 261 | error.BinaryNotFound, error.SpawnFailed => { | ||
| 262 | // std.debug.print rather than progress.emitFmt: an exe path can | ||
| 263 | // run to max_path_bytes, and emitFmt's fixed buffer would drop | ||
| 264 | // the whole line rather than shorten it. | ||
| 265 | std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, exe, @errorName(err) }); | ||
| 266 | return false; | ||
| 267 | }, | ||
| 268 | }; | ||
| 269 | return true; | ||
| 270 | } | ||
| 271 | |||
| 179 | /// Walk a colon-separated `path_env` for an executable `name`; the first | 272 | /// Walk a colon-separated `path_env` for an executable `name`; the first |
| 180 | /// hit wins, execvp's own rule. Caller owns the returned path. Takes the | 273 | /// hit wins, execvp's own rule. Caller owns the returned path. Takes the |
| 181 | /// PATH string rather than reading the environment so tests stay | 274 | /// PATH string rather than reading the environment so tests stay |
| @@ -242,7 +335,7 @@ test "ensureDaemon: an answering socket is already_running, nothing spawned" { | |||
| 242 | sock, | 335 | sock, |
| 243 | progress, | 336 | progress, |
| 244 | 200, | 337 | 200, |
| 245 | null, | 338 | .{ .truncate = true }, |
| 246 | ); | 339 | ); |
| 247 | try std.testing.expectEqual(Ensured.already_running, r); | 340 | try std.testing.expectEqual(Ensured.already_running, r); |
| 248 | 341 | ||
| @@ -263,7 +356,7 @@ test "ensureDaemon: missing binary is BinaryNotFound before any fork" { | |||
| 263 | sock, | 356 | sock, |
| 264 | silentProgress(), | 357 | silentProgress(), |
| 265 | 200, | 358 | 200, |
| 266 | null, | 359 | .{ .truncate = true }, |
| 267 | )); | 360 | )); |
| 268 | } | 361 | } |
| 269 | 362 | ||
| @@ -293,7 +386,7 @@ test "ensureDaemon: a child that dies young is reported, not panicked on" { | |||
| 293 | sock, | 386 | sock, |
| 294 | silentProgress(), | 387 | silentProgress(), |
| 295 | 300, | 388 | 300, |
| 296 | log, | 389 | .{ .path = log, .truncate = true }, |
| 297 | )); | 390 | )); |
| 298 | // The log is still there to be named by the failure line: a child that | 391 | // The log is still there to be named by the failure line: a child that |
| 299 | // died is exactly when an operator goes looking for it. | 392 | // died is exactly when an operator goes looking for it. |
| @@ -332,7 +425,7 @@ test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive" | |||
| 332 | sock, | 425 | sock, |
| 333 | silentProgress(), | 426 | silentProgress(), |
| 334 | 300, | 427 | 300, |
| 335 | log, | 428 | .{ .path = log, .truncate = true }, |
| 336 | )); | 429 | )); |
| 337 | // The log is created before the fork, so it exists even when the child | 430 | // The log is created before the fork, so it exists even when the child |
| 338 | // never writes to it — that is what makes it the place to look when a | 431 | // never writes to it — that is what makes it the place to look when a |
| @@ -358,6 +451,68 @@ test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive" | |||
| 358 | _ = std.posix.waitpid(last_spawned_pid, 0); | 451 | _ = std.posix.waitpid(last_spawned_pid, 0); |
| 359 | } | 452 | } |
| 360 | 453 | ||
| 454 | test "ensureDaemon: an attach appends to the log, `muxd start` truncates it" { | ||
| 455 | var tmp = try testtmp.TmpDir.make(); | ||
| 456 | defer tmp.cleanup(); | ||
| 457 | var pbuf: [128]u8 = undefined; | ||
| 458 | var sbuf: [128]u8 = undefined; | ||
| 459 | var lbuf: [128]u8 = undefined; | ||
| 460 | const stub = try std.fmt.bufPrint(&pbuf, "{s}/dies.sh", .{tmp.path()}); | ||
| 461 | const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()}); | ||
| 462 | const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()}); | ||
| 463 | |||
| 464 | // Exits at once and binds nothing, so both spawns below end in | ||
| 465 | // NeverAnswered — which is beside the point. The log is opened BEFORE | ||
| 466 | // the fork, so what happened to the bytes already in it is settled | ||
| 467 | // whether the daemon ever comes up or not, and a stub that writes | ||
| 468 | // nothing keeps the two file contents readable as pure evidence of the | ||
| 469 | // open mode. | ||
| 470 | try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" }); | ||
| 471 | const f = try tmp.dir.openFile("dies.sh", .{}); | ||
| 472 | try f.chmod(0o755); | ||
| 473 | f.close(); | ||
| 474 | |||
| 475 | const seed = "a live daemon was writing here\n"; | ||
| 476 | try std.fs.cwd().makePath(std.fs.path.dirname(log).?); | ||
| 477 | try std.fs.cwd().writeFile(.{ .sub_path = log, .data = seed }); | ||
| 478 | |||
| 479 | // The attach shape must leave it alone. Without this, one `mux` over | ||
| 480 | // ssh — no XDG_RUNTIME_DIR, so a /tmp socket nobody happens to be | ||
| 481 | // serving — zeroes the log of the interactive daemon still writing to | ||
| 482 | // it, and the operator reads a hole where the crash was. | ||
| 483 | try std.testing.expectError(error.NeverAnswered, ensureDaemon( | ||
| 484 | std.testing.allocator, | ||
| 485 | stub, | ||
| 486 | &.{}, | ||
| 487 | sock, | ||
| 488 | silentProgress(), | ||
| 489 | 100, | ||
| 490 | .{ .path = log, .truncate = false }, | ||
| 491 | )); | ||
| 492 | var rbuf: [256]u8 = undefined; | ||
| 493 | // Equality, not "contains": appending must add at the END, so a mode | ||
| 494 | // that wrote over the front and happened to be shorter still fails. | ||
| 495 | try std.testing.expectEqualStrings(seed, try std.fs.cwd().readFile(log, &rbuf)); | ||
| 496 | |||
| 497 | // `muxd start` still truncates, and that half is asserted here rather | ||
| 498 | // than assumed: the two verbs differ only in this flag, so a change that | ||
| 499 | // made everything append would otherwise pass unnoticed until an | ||
| 500 | // operator read a restarted daemon's log and found the old one's. | ||
| 501 | try std.testing.expectError(error.NeverAnswered, ensureDaemon( | ||
| 502 | std.testing.allocator, | ||
| 503 | stub, | ||
| 504 | &.{}, | ||
| 505 | sock, | ||
| 506 | silentProgress(), | ||
| 507 | 100, | ||
| 508 | .{ .path = log, .truncate = true }, | ||
| 509 | )); | ||
| 510 | try std.testing.expectEqual( | ||
| 511 | @as(usize, 0), | ||
| 512 | (try std.fs.cwd().readFile(log, &rbuf)).len, | ||
| 513 | ); | ||
| 514 | } | ||
| 515 | |||
| 361 | test "findInPath: first executable hit wins; non-executables are skipped" { | 516 | test "findInPath: first executable hit wins; non-executables are skipped" { |
| 362 | const alloc = std.testing.allocator; | 517 | const alloc = std.testing.allocator; |
| 363 | var tmp = try testtmp.TmpDir.make(); | 518 | var tmp = try testtmp.TmpDir.make(); |
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -1084,7 +1084,7 @@ rm -f "$OUT.q" "$OUT.qc" "$OUT.qr" "$OUT.qa" "$OUT.qk" "$QKEY" "$QKEY.bad" "$QKE | |||
| 1084 | "$MUXD" start --sock "$SOCK8" 2> "$OUT.start" | 1084 | "$MUXD" start --sock "$SOCK8" 2> "$OUT.start" |
| 1085 | grep -q '^muxd: starting' "$OUT.start" || { | 1085 | grep -q '^muxd: starting' "$OUT.start" || { |
| 1086 | echo "e2e FAIL: start printed no starting line"; cat "$OUT.start"; exit 1; } | 1086 | echo "e2e FAIL: start printed no starting line"; cat "$OUT.start"; exit 1; } |
| 1087 | grep -q ' up (' "$OUT.start" || { | 1087 | grep -q '^up (' "$OUT.start" || { |
| 1088 | echo "e2e FAIL: start printed no up line"; cat "$OUT.start"; exit 1; } | 1088 | echo "e2e FAIL: start printed no up line"; cat "$OUT.start"; exit 1; } |
| 1089 | # Known gap, accepted: between the spawn above and this capture the daemon | 1089 | # Known gap, accepted: between the spawn above and this capture the daemon |
| 1090 | # is running with no pid the trap can reach, so a failure in the two | 1090 | # is running with no pid the trap can reach, so a failure in the two |