src/cli/main.zig
Ref: Size: 113.3 KiB History
//! `mux d` — the daemon mode. `start` hosts the session; `dump` prints the
//! authoritative grid over the protocol (debug aid, also used by e2e);
//! `proxy` exposes the session socket over stdio for `mux --via`.
// Rationale: spawning the user's shell is the daemon's purpose;
// `/bin/sh` is the executable fallback when `$SHELL` is unset.
const std = @import("std");
const builtin = @import("builtin");
const Server = @import("daemon").Server;
const proto = @import("term").protocol;
const proxy = @import("proxy");
const quic = @import("quic");
const quic_server = @import("daemon").quic_server;
const build_options = @import("build_options");
const xdg = @import("xdg");
const spawn = @import("spawn");
const server_os = @import("server_os");
const handoff = @import("client").handoff;
const sockpath = @import("sockpath");
const upgrade = @import("daemon").upgrade;
const cliflags = @import("cliflags");
const dial = @import("dial");
const link_mod = @import("link");
const usage =
\\usage:
\\ mux d start [-d] [--sock PATH] [--shell PATH] [--cols N] [--rows N]
\\ [--quic HOST[:PORT] --key FILE] [--quic-idle-ms N]
\\ (-d comes first; forks it off and waits, no-op if up)
\\ mux d dump [--vt] [--session NAME] [--sock PATH | --quic HOST[:PORT] [--key FILE]]
\\ mux d stats [--sock PATH | --quic HOST[:PORT] [--key FILE]]
\\ mux d stop [--sock PATH | --quic HOST[:PORT] [--key FILE]] (ask the daemon to exit)
\\ mux d proxy [--sock PATH] (byte pump: stdio <-> session socket)
\\ mux d endpoint [--sock PATH] [--start] (announce QUIC port+key, then proxy)
\\ mux d keygen (write a fresh key to ~/.config/mux/key)
\\ mux d upgrade [HOST] [--sock PATH] (exec THIS binary over the daemon; sessions
\\ live. HOST: push this image there over ssh, then upgrade its daemon)
\\ [--allow-same-version] (strictly newer, unless this; the e2e leg's)
\\ mux d --version
\\ mux d --help
\\
;
const DaemonCommand = enum { dump, stats, proxy, endpoint, version, help, keygen, start, stop, upgrade };
/// Metadata for one daemon command. Dispatch-independent behavior is derived
/// from this table and checked for completeness at compile time.
const CommandSpec = struct {
name: []const u8,
cmd: DaemonCommand,
/// Whether the command uses the resolved socket path. Commands that do not
/// touch a socket must not fail because the default path is invalid.
uses_socket: bool,
/// How trailing arguments are handled. `all` runs the shared parser,
/// `none` rejects every argument, and `ignored` skips parsing entirely.
/// The ignored behavior is compatibility-tested for help and version.
flags: enum { none, all, ignored },
};
const specs = [_]CommandSpec{
// Table order has no effect because lookup is by exact name. Version and
// help are represented as commands even though users spell them as flags.
.{ .name = "--version", .cmd = .version, .uses_socket = false, .flags = .ignored },
// Help ignores adjacent arguments so requested usage is not replaced by a
// syntax diagnostic.
.{ .name = "--help", .cmd = .help, .uses_socket = false, .flags = .ignored },
.{ .name = "start", .cmd = .start, .uses_socket = true, .flags = .all },
.{ .name = "dump", .cmd = .dump, .uses_socket = true, .flags = .all },
.{ .name = "stats", .cmd = .stats, .uses_socket = true, .flags = .all },
.{ .name = "proxy", .cmd = .proxy, .uses_socket = true, .flags = .all },
.{ .name = "endpoint", .cmd = .endpoint, .uses_socket = true, .flags = .all },
// `keygen` accepts no options because it always writes the default path.
.{ .name = "keygen", .cmd = .keygen, .uses_socket = false, .flags = .none },
.{ .name = "stop", .cmd = .stop, .uses_socket = true, .flags = .all },
.{ .name = "upgrade", .cmd = .upgrade, .uses_socket = true, .flags = .all },
};
comptime {
for (std.enums.values(DaemonCommand)) |c| {
var rows = 0;
for (specs) |s| {
if (s.cmd == c) rows += 1;
}
// Require exactly one row per command. Duplicate rows would make one
// name unreachable and make `specForCmd` depend on table order.
if (rows == 0) @compileError("DaemonCommand has no row in specs: " ++ @tagName(c));
if (rows > 1) @compileError("DaemonCommand has more than one row in specs: " ++ @tagName(c));
}
}
/// Look up an exact command name; prefixes such as `ru` remain syntax errors.
fn specForName(name: []const u8) ?CommandSpec {
for (specs) |s| {
if (std.mem.eql(u8, name, s.name)) return s;
}
return null;
}
/// Return the row for a command. The compile-time table check makes failure
/// unreachable.
fn specForCmd(cmd: DaemonCommand) CommandSpec {
for (specs) |s| {
if (s.cmd == cmd) return s;
}
unreachable;
}
/// Parsed arguments for a daemon command. Parsing is separate from `main` so
/// syntax and defaults can be unit tested without process exit.
const DaemonArguments = struct {
/// Selected before flag parsing; the leading underscore excludes it from
/// generated flags.
_cmd: DaemonCommand,
/// Set only when `start` has `-d` or `--detach` in its first argument
/// position. True starts a child and waits for its socket; false runs in the
/// foreground.
_detach: bool = false,
sock: ?[]const u8 = null,
shell: ?[]const u8 = null,
cols: u16 = 80,
rows: u16 = 24,
vt: bool = false,
quic: ?[]const u8 = null,
/// An explicit key path. Null still allows `MUX_KEY_FILE` or the default
/// path to be resolved later. `parseArgs` rejects only the inverse case,
/// `--key` without `--quic`.
key: ?[]const u8 = null,
/// `quic.IdleMs` rejects zero and values outside u32.
quic_idle_ms: quic.IdleMs = .{},
/// Session selected by `dump`. Validation applies only to an explicitly
/// supplied name; the wire uses an empty tail for the default session.
session: ?proto.SessionName = null,
/// Manifest descriptor supplied by the old daemon during upgrade. Its
/// presence makes `start` adopt existing state instead of resolving a new
/// socket path.
resume_fd: ?std.posix.fd_t = null,
/// Validate the complete manifest without adopting it. The old daemon uses
/// this child-process check before relinquishing service.
check: bool = false,
/// Test-only failure injection after the named manifest section. It verifies
/// rollback after a candidate has read the manifest but cannot adopt it.
resume_fail_at: ?[]const u8 = null,
/// Allow the upgrade e2e test to reuse the same binary instead of requiring
/// a strictly newer version.
allow_same_version: bool = false,
/// `endpoint`-only option that starts the daemon before announcing it. A
/// direct cold attach uses this flag; background polls omit it and remain
/// read-only.
start: bool = false,
/// `upgrade`'s one positional: the ssh host to push this image to and
/// upgrade there. Underscored out of generated flags; every other verb
/// leaves the hook cold so a bare word stays a syntax error on them.
_host: ?[]const u8 = null,
pub fn positional(self: *DaemonArguments, word: []const u8) bool {
if (self._cmd != .upgrade or self._host != null) return false;
self._host = word;
return true;
}
};
// These upgrade-only flags are generated by the old daemon and intentionally
// omitted from user-facing usage text.
comptime {
cliflags.assertDocumented(DaemonArguments, usage, &.{ "resume_fd", "check", "resume_fail_at" });
}
/// Usage or help response produced instead of executing a daemon command.
/// Invalid syntax is reported without a stack trace; help exits successfully.
const UsageResponse = union(enum) {
no_command,
/// Requested help, the only response that exits successfully.
help,
unknown_command: []const u8,
unknown_arg: []const u8,
/// A flag at the end of argv with nothing left to consume.
missing_value: []const u8,
/// The flag whose value its own type would not hold.
bad_value: []const u8,
key_without_quic,
/// `upgrade HOST --sock PATH`: the socket that matters on a remote
/// upgrade is the remote box's own default, and a --sock honored
/// locally while HOST is honored remotely would leave the user
/// believing both.
sock_with_host,
/// `stop|dump|stats --sock PATH --quic HOST:PORT`: two daemons named
/// for one question. `start` takes both, because a daemon can listen
/// on both; the admin verbs ask one thing of one daemon.
sock_with_quic,
};
const DaemonInvocation = union(enum) { command: DaemonArguments, usage: UsageResponse };
/// The verbs that may reach a daemon over QUIC instead of its socket path:
/// one frame in, one frame out, which `handleDaemonVerb` already serves on a
/// QUIC client slot. `upgrade` stays on the socket — its request is served
/// to observers only, and the manifest it carries is local to the box.
fn quicAdmin(cmd: DaemonCommand) bool {
return cmd == .stop or cmd == .dump or cmd == .stats;
}
fn parseArgs(args: []const [:0]const u8) DaemonInvocation {
if (args.len < 2) return .{ .usage = .no_command };
const spec = specForName(args[1]) orelse return .{ .usage = .{ .unknown_command = args[1] } };
// The one place a verb's flag class is enforced; which class each verb
// is in is stated once, in its row.
switch (spec.flags) {
.ignored => return .{ .command = .{ ._cmd = spec.cmd } },
.none => if (args.len > 2) return .{ .usage = .{ .unknown_arg = args[2] } },
.all => {},
}
var o: DaemonArguments = .{ ._cmd = spec.cmd };
// `-d` is recognized only in the first argument position after `start`.
// Keeping it outside the generic flag struct makes it unknown for every
// other command without separate checks.
const flag_args = if (spec.cmd == .start and args.len > 2 and
(std.mem.eql(u8, args[2], "-d") or std.mem.eql(u8, args[2], "--detach")))
blk: {
o._detach = true;
break :blk args[3..];
} else args[2..];
switch (cliflags.parse(DaemonArguments, &o, flag_args)) {
.ok => {},
.help => return .{ .usage = .help },
// Version takes precedence anywhere on the line and requires neither a
// runtime directory nor a running daemon.
.version => return .{ .command = .{ ._cmd = .version } },
.unknown_arg => |a| return .{ .usage = .{ .unknown_arg = a } },
.missing_value => |f| return .{ .usage = .{ .missing_value = f } },
.bad_value => |f| return .{ .usage = .{ .bad_value = f } },
}
// Reject `--start` on every command except `endpoint`, including the likely
// typo `mux d start --start`.
if (o.start and spec.cmd != .endpoint) return .{ .usage = .{ .unknown_arg = "--start" } };
// `--key` without `--quic` is always invalid. The reverse is valid because
// `run` may resolve `MUX_KEY_FILE` or the default key path.
if (o.key != null and o.quic == null) return .{ .usage = .key_without_quic };
if (o._host != null and o.sock != null) return .{ .usage = .sock_with_host };
if (quicAdmin(spec.cmd) and o.quic != null and o.sock != null) return .{ .usage = .sock_with_quic };
// `--quic` is a bind for `start` and a door for the admin verbs. Every
// other verb is served on the socket alone, and a flag it parsed and
// dropped would run `mux d upgrade --quic HOST` against the socket's
// daemon while the user believed HOST was being upgraded.
if (o.quic != null and spec.cmd != .start and !quicAdmin(spec.cmd)) return .{ .usage = .{ .unknown_arg = "--quic" } };
return .{ .command = o };
}
/// Return the exit code for a usage response without printing it.
fn usageCode(u: UsageResponse) u8 {
return if (u == .help) 0 else 2;
}
fn usageExit(u: UsageResponse) u8 {
switch (u) {
.help => return cliflags.help(usage),
.no_command => std.debug.print("{s}", .{usage}),
.unknown_command => std.debug.print("{s}", .{usage}),
.unknown_arg => |a| std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }),
.missing_value => |f| std.debug.print("mux d: {s} needs a value\n{s}", .{ f, usage }),
.bad_value => |f| std.debug.print("mux d: {s} was given a value it cannot hold\n{s}", .{ f, usage }),
.key_without_quic => std.debug.print(
"mux d: --key without --quic has nothing to listen on; name both or neither\n",
.{},
),
.sock_with_host => std.debug.print(
"mux d: upgrade HOST uses that box's own default socket; --sock only names a local one\n",
.{},
),
.sock_with_quic => std.debug.print(
"mux d: --sock and --quic name two daemons; ask one of them\n",
.{},
),
}
return usageCode(u);
}
/// Parse a numeric bind address without DNS; a hostname may resolve to several
/// addresses and does not identify one deterministic bind target.
fn parseBindAddr(s: []const u8) !std.net.Address {
const hp = try quic.splitHostPort(s);
return std.net.Address.parseIp(hp.host, hp.port);
}
/// Run daemon mode using the argv slice supplied by the top-level dispatcher.
pub fn main(args: []const [:0]const u8) !u8 {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer if (gpa.deinit() == .leak)
std.debug.print("mux d: LEAK: allocations outlived deinit\n", .{});
const alloc = gpa.allocator();
const o = switch (parseArgs(args)) {
.usage => |u| return usageExit(u),
.command => |o| o,
};
// Resolve a socket path only for commands that use one. Version, help, and
// key generation must not depend on runtime-directory state. Resume mode
// receives its already-bound path through the manifest.
// A remote upgrade names a HOST, and the socket that matters is that
// box's own default; resolving a local one here would refuse the verb on
// a machine with no XDG_RUNTIME_DIR for no reason it could name.
// An admin verb aimed at `--quic` resolves no socket path either: the
// daemon it is for may have none, and the box asking may have no
// runtime dir.
const uses_socket = specForCmd(o._cmd).uses_socket and o.resume_fd == null and o._host == null and
!(quicAdmin(o._cmd) and o.quic != null);
const sock_path = if (o.sock) |s|
try alloc.dupe(u8, s)
else if (!uses_socket)
try alloc.dupe(u8, "")
else
try sockpath.defaultOrExplain(alloc, "mux d") orelse return 1;
defer alloc.free(sock_path);
// Reject an overlong Unix socket path before executing a command; otherwise
// detached startup would fail later as a misleading timeout. Command
// metadata determines which commands are exempt.
if (uses_socket and sockpath.tooLong("mux d", sock_path)) return 1;
switch (o._cmd) {
// Version does not use or validate the resolved socket path.
.version => return cliflags.version("mux", build_options.version),
.help => return usageExit(.help),
.keygen => return keygen(alloc),
.start => return if (o.resume_fd) |fd|
resumeRun(alloc, o, fd)
else
startCmd(alloc, o, sock_path, args[2..]),
.dump => return dump(alloc, adminTarget(o, sock_path), o.vt, if (o.session) |n| n.name else ""),
.stats => return stats(alloc, adminTarget(o, sock_path)),
.stop => return stopCmd(alloc, adminTarget(o, sock_path)),
.upgrade => return if (o._host) |h|
remoteUpgradeCmd(alloc, h, o.allow_same_version)
else
upgradeCmd(alloc, sock_path, o.allow_same_version),
// Proxy only pumps an existing daemon connection; it never starts a
// daemon. `proxy.run` reports the socket path when connection fails.
.proxy => return proxy.run(sock_path),
.endpoint => return endpointCmd(alloc, sock_path, std.posix.STDOUT_FILENO, o.start),
}
}
/// Enable shell integration only for the exact value `1`; the integration
/// changes zsh startup and uses Bash's DEBUG trap.
fn shellIntegrationEnabled(env: ?[]const u8) bool {
return std.mem.eql(u8, env orelse "", "1");
}
/// Maximum upgrade manifest size: large enough for the full session table but
/// bounded so an invalid descriptor cannot cause unbounded allocation.
const manifest_read_max = 64 * 1024 * 1024;
/// Rollback marker read by the next adoption attempt. It prevents two binaries
/// that reject the same manifest from repeatedly executing each other. An
/// environment variable remains compatible with older rollback targets that
/// would reject an unknown flag.
const rollback_marker = "MUX_UPGRADE_ROLLBACK";
/// Environment equivalent of the test-only `--resume-fail-at` option. Upgrade
/// exec builds fixed argv but preserves this variable for e2e failure injection.
const fail_at_env = "MUX_RESUME_FAIL_AT";
/// Prefer the explicit failure-injection flag over its environment fallback.
fn failAtFrom(flag: ?[]const u8, env: ?[]const u8) []const u8 {
return flag orelse env orelse "";
}
/// What the rollback exec keeps from this process's environment.
fn rollbackKeepsEnv(entry: []const u8) bool {
// The abort that caused the rollback must not be inherited by the
// binary being rolled back TO: it would abort at the same section,
// find the marker, give up, and take every shell with it.
return !std.mem.startsWith(u8, entry, fail_at_env ++ "=");
}
/// libc declaration used to remove the rollback marker before serving. Leaving
/// it set would disable a later rollback and propagate it to spawned shells.
extern "c" fn unsetenv(name: [*:0]const u8) c_int;
/// Execute the previous daemon binary with the still-open manifest descriptor.
fn rollback(
alloc: std.mem.Allocator,
writer_path: []const u8,
resume_fd: std.posix.fd_t,
section: []const u8,
) u8 {
// Rollback occurs before serving begins, while all inherited descriptors
// remain open and the manifest still identifies the previous binary.
std.debug.print(
"mux d start: adoption failed at {s}; exec'ing {s} back\n",
.{ section, writer_path },
);
if (std.posix.getenv(rollback_marker) != null) {
std.debug.print(
"mux d start: this IS the rollback ({s} refused the manifest it wrote); giving up\n",
.{writer_path},
);
return 1;
}
// The manifest was read to EOF and the descriptor is the one being
// passed on: the next binary starts where the writer left it.
var file = std.fs.File{ .handle = resume_fd };
file.seekTo(0) catch {};
var fd_buf: [12]u8 = undefined;
const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{resume_fd}) catch return 1;
// `d start`, the only spelling there is. The binary being exec'd back is
// older than this one, but not by more than the rename: a daemon of
// v0.0.1-15 or earlier refuses this binary as a candidate at its
// version probe, so it can never have been the writer here.
const argv = [_:null]?[*:0]const u8{ "mux", "d", "start", "--resume-fd", fd_str.ptr };
const path_z = alloc.dupeZ(u8, writer_path) catch return 1;
const envp = rollbackEnvp(alloc) catch return 1;
// Successful exec does not return. If rollback exec fails, process exit
// closes PTY masters and sends the same SIGHUP effect as stop followed by
// a fresh run.
const exec_err = std.posix.execveZ(path_z.ptr, &argv, envp);
alloc.free(path_z);
alloc.free(std.mem.span(envp));
std.debug.print(
"mux d start: rollback exec of {s} failed: {s}\n",
.{ writer_path, @errorName(exec_err) },
);
return 1;
}
/// Return this process's environment plus the rollback marker. The allocation
/// intentionally lives until the immediately following execve.
fn rollbackEnvp(alloc: std.mem.Allocator) ![*:null]const ?[*:0]const u8 {
var n: usize = 0;
while (std.c.environ[n] != null) n += 1;
const envp = try alloc.allocSentinel(?[*:0]const u8, n + 1, null);
var kept: usize = 0;
for (0..n) |i| {
const entry = std.c.environ[i].?;
if (!rollbackKeepsEnv(std.mem.span(entry))) continue;
envp[kept] = entry;
kept += 1;
}
envp[kept] = rollback_marker ++ "=1";
kept += 1;
// The dropped entries leave a tail of undefined pointers between the
// last kept one and the sentinel; execve reads to the first null.
for (envp[kept..]) |*slot| slot.* = null;
return envp.ptr;
}
/// Adopt a daemon from the manifest descriptor supplied by an upgrade exec.
/// The process keeps its pid, children, and descriptors. Reading directly from
/// the carrier, which no path names, also keeps the embedded QUIC key off disk.
fn resumeRun(alloc: std.mem.Allocator, o: DaemonArguments, resume_fd: std.posix.fd_t) !u8 {
// The writer left the offset at the end of what it wrote, and a child
// shares the file description with it, so the rewind is ours to do.
var file = std.fs.File{ .handle = resume_fd };
file.seekTo(0) catch |err| {
std.debug.print("mux d start: --resume-fd {d} does not seek ({t})\n", .{ resume_fd, err });
return 1;
};
const bytes = file.readToEndAlloc(alloc, manifest_read_max) catch |err| {
std.debug.print("mux d start: cannot read the manifest on fd {d} ({t})\n", .{ resume_fd, err });
return 1;
};
defer alloc.free(bytes);
var parsed = upgrade.parseManifest(alloc, bytes) catch |err| {
std.debug.print("mux d start: manifest on fd {d} is not one ({t})\n", .{ resume_fd, err });
return 1;
};
defer parsed.deinit();
// `--check` validates the complete manifest in a child and adopts nothing.
// It precedes failure injection because a validation child must never roll
// back or replace the live daemon.
if (o.check) return 0;
const fail_at = failAtFrom(o.resume_fail_at, std.posix.getenv(fail_at_env));
if (std.mem.eql(u8, fail_at, "daemon"))
return rollback(alloc, parsed.daemon.writer_path, resume_fd, "daemon (--resume-fail-at)");
var srv = Server.initFromManifest(alloc, &parsed, build_options.version) catch |err| {
var reason: [64]u8 = undefined;
return rollback(alloc, parsed.daemon.writer_path, resume_fd, std.fmt.bufPrint(&reason, "sessions ({t})", .{err}) catch "sessions");
};
// Before the teardown defer, never after: a rollback must leave every
// shell, socket and directory exactly as it found them, and deinit is
// the demolition list.
if (std.mem.eql(u8, fail_at, "session"))
return rollback(alloc, parsed.daemon.writer_path, resume_fd, "session (--resume-fail-at)");
// The last rollback point is behind us, so the descriptors it would have
// handed back are this image's to keep — and to stop handing on. Rollback
// was the manifest's last reader; left open, it is CLOEXEC-cleared like
// the rest and every shell spawned from here inherits it.
std.posix.close(resume_fd);
srv.sealAdoptedFds();
defer srv.deinit();
// The marker's job ended the moment this image started serving.
_ = unsetenv(rollback_marker);
@import("daemon").installSignalHandlers();
return try srv.run();
}
/// Run the daemon in the foreground. Startup failures occur before serving;
/// once `srv.run` is reached, the daemon returns zero when service ends.
fn run(alloc: std.mem.Allocator, o: DaemonArguments, sock_path: []const u8) !u8 {
// Resolve the QUIC address and key before binding or starting a shell so
// configuration errors leave no session socket or child process behind.
var quic_bind: ?std.net.Address = null;
var quic_key: quic.Key = undefined;
if (o.quic) |hostport| {
quic_bind = parseBindAddr(hostport) catch {
std.debug.print(
"mux d: --quic wants HOST:PORT with a literal address, got {s}\n",
.{hostport},
);
return 1;
};
// Resolve `--key`, then `MUX_KEY_FILE`, then the default path, matching
// direct QUIC clients. Only QUIC-enabled startup requires a key.
const key_res = try xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env)));
defer key_res.deinit(alloc);
const key_path = switch (key_res) {
.given, .default => |p| p,
.missing => |p| {
std.debug.print(
"mux d: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen` (default {s})\n",
.{p},
);
return 2;
},
};
quic_key = quic.Key.load(key_path) catch |err| switch (err) {
// The three the user can act on, in quic.zig's words — its one
// owner, since three callers print the same sentences. Anything
// else propagates: `run` is a foreground start that may fail, where
// an announce path must stay on ssh and so flattens instead.
error.KeyFileMissing,
error.KeyFilePermissive,
error.KeyFileMalformed,
=> {
var buf: [quic.key_refusal_len]u8 = undefined;
std.debug.print("mux d: {s}\n", .{quic.keyRefusalBody(&buf, err, key_path)});
return 1;
},
else => return err,
};
}
// Bind UDP before the session socket so a conflicting QUIC port leaves no
// shell process or Unix socket behind.
var listener: ?*quic_server.Listener = null;
if (quic_bind) |addr| {
listener = quic_server.Listener.bind(alloc, addr, quic_key, o.quic_idle_ms.ms) catch |err| switch (err) {
// The listener intentionally omits SO_REUSEADDR. Report an occupied
// QUIC port instead of sharing datagrams with another daemon.
error.AddressInUse => {
std.debug.print(
"mux d: a daemon is already listening on udp {s}\n",
.{o.quic.?},
);
return 1;
},
else => {
std.debug.print(
"mux d: cannot listen on udp {s}: {s}\n",
.{ o.quic.?, @errorName(err) },
);
return 1;
},
};
}
// Registered before the server's, so it runs after it: a client slot
// backed by QUIC closes through the listener, and the server must finish
// tearing its slots down before the listener is freed.
defer if (listener) |l| l.deinit();
const shell_z: [:0]const u8 = if (o.shell) |s|
try alloc.dupeZ(u8, s)
else
// folder rule 5 exemption: The daemon invokes the session shell directly as argv.
try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh");
defer alloc.free(shell_z);
// Shell integration comes from the daemon environment because the daemon
// starts the session shell before any client can provide options.
const shell_integration = shellIntegrationEnabled(
std.posix.getenv("MUX_SHELL_INTEGRATION"),
);
var srv = Server.init(alloc, .{
.sock_path = sock_path,
.shell = shell_z,
.cols = o.cols,
.rows = o.rows,
.shell_integration = shell_integration,
.version = build_options.version,
}) catch |err| switch (err) {
// All of these mean "that path is not ours to take", and all are
// operator mistakes: one line, no stack trace. AddressInUse is the same
// race found one syscall later, and the loser is told the truth — by
// the time it reads the message, a daemon IS running.
error.DaemonAlreadyRunning, error.AddressInUse => {
std.debug.print("mux d: a daemon is already running on {s}\n", .{sock_path});
return 1;
},
error.SockPathNotASocket => {
std.debug.print(
"mux d: {s} exists and is not a socket (move it, or name another with --sock)\n",
.{sock_path},
);
return 1;
},
else => return err,
};
defer srv.deinit();
if (listener) |l| {
l.setHandler(srv.quicHandler());
srv.attachQuic(l);
}
@import("daemon").installSignalHandlers();
return try srv.run();
}
/// Where an admin verb sends its one frame. `sock` is the path every verb
/// has always taken. `quic` is the second door, for the daemon the first
/// cannot reach: one whose socket file was deleted and whose path a
/// successor now holds, so the re-bind in `Server.watchSockPath` is
/// refused and the daemon is still holding sessions nobody can stop,
/// dump or count (issue 145807a2). `mux a` has dialled daemons this way
/// since QUIC existed; the daemon serves these verbs on a client slot
/// exactly as on an observer, so this is the client learning the spelling.
const AdminTarget = union(enum) {
sock: []const u8,
quic: struct { host_port: []const u8, key: ?[]const u8 },
};
fn adminTarget(o: DaemonArguments, sock_path: []const u8) AdminTarget {
if (o.quic) |hp| return .{ .quic = .{ .host_port = hp, .key = o.key } };
return .{ .sock = sock_path };
}
/// The one bound QUIC asks live under. A unix socket says "nothing
/// listening" as an errno in a microsecond, so `oneShotQuery` on a path
/// may wait forever on a daemon that answered the connect; UDP has no
/// such errno — an unanswered port is silence — so every QUIC ask is
/// bounded, and the silence is reported as what it might be.
const admin_quic_deadline_ms: u32 = 5000;
/// One deadline for the whole ask, minted by the caller and spent by the
/// handshake and the reply together: two full budgets in a row made "5 s"
/// a ten-second wait on a port that took the handshake and nothing else.
fn adminDeadline() i64 {
return std.time.milliTimestamp() + admin_quic_deadline_ms;
}
fn msLeft(deadline: i64) u32 {
const left = deadline - std.time.milliTimestamp();
return if (left <= 0) 0 else @intCast(@min(left, admin_quic_deadline_ms));
}
/// Dial a daemon's QUIC arm for one admin verb: key by the same three-way
/// rule `mux a --quic` and `mux d start --quic` use (`--key`, then
/// `MUX_KEY_FILE`, then the default path), handshake within the deadline.
/// Every refusal is printed here in the verb's voice and answered with
/// null; the caller's only job is the frame.
fn openAdminQuic(alloc: std.mem.Allocator, verb: []const u8, q: anytype, deadline: i64) ?link_mod.Link {
const res = xdg.resolveKeyPath(alloc, xdg.pickKey(q.key, std.posix.getenv(xdg.key_env))) catch |e| {
std.debug.print("mux d {s}: quic: cannot resolve a key path: {s}\n", .{ verb, @errorName(e) });
return null;
};
defer res.deinit(alloc);
const key_path = switch (res) {
.given, .default => |p| p,
.missing => |p| {
std.debug.print(
"mux d {s}: no key: pass --key, set MUX_KEY_FILE, or run `mux d keygen` (default {s})\n",
.{ verb, p },
);
return null;
},
};
const key = quic.Key.load(key_path) catch |e| {
var buf: [quic.key_refusal_len]u8 = undefined;
std.debug.print("mux d {s}: {s}\n", .{ verb, quic.keyRefusalBody(&buf, e, key_path) });
return null;
};
const addr = quic.parseAddr(alloc, q.host_port) catch |e| {
std.debug.print("mux d {s}: --quic wants HOST[:PORT], got {s}: {s}\n", .{ verb, q.host_port, @errorName(e) });
return null;
};
const cl = quic.Client.connect(alloc, addr, key, quic.default_idle_ms) catch |e| {
std.debug.print("mux d {s}: cannot dial quic://{s}: {s}\n", .{ verb, q.host_port, @errorName(e) });
return null;
};
while (!cl.isReady()) {
cl.pump();
if (cl.dead or std.time.milliTimestamp() >= deadline) {
cl.deinit();
std.debug.print(
"mux d {s}: quic://{s} did not answer in {d}s (nothing there, or not this key)\n",
.{ verb, q.host_port, admin_quic_deadline_ms / 1000 },
);
return null;
}
var fds = [_]std.posix.pollfd{
.{ .fd = cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
};
_ = std.posix.poll(&fds, cl.timeoutMs(50)) catch {};
}
return .{ .quic = .{ .cl = cl, .alloc = alloc } };
}
/// Perform a one-shot query. On a socket it is unbounded: a connected
/// daemon that stops replying remains a visible hang rather than being
/// reported like an absent socket. Over QUIC it is bounded, for the reason
/// on `admin_quic_deadline_ms`.
fn oneShotQuery(
alloc: std.mem.Allocator,
target: AdminTarget,
verb: []const u8,
req: proto.MsgType,
req_payload: []const u8,
want: proto.MsgType,
) !u8 {
const frame = switch (target) {
.sock => |sock_path| (dial.ask(alloc, sock_path, req, req_payload, want, null) catch |e| switch (e) {
error.NoDaemon => {
std.debug.print(
"mux d {s}: nothing listening on {s} (`mux d start -d` starts one)\n",
.{ verb, sock_path },
);
return 1;
},
// A reply this side could not read, or a daemon that hung up over the
// request: both stay errors, so a broken daemon never reads as an
// absent one.
else => return e,
}) orelse return 1,
.quic => |q| blk: {
const deadline = adminDeadline();
var l = openAdminQuic(alloc, verb, q, deadline) orelse return 1;
// The goodbye: a QUIC connection holds one of the daemon's client
// slots until CONNECTION_CLOSE or its idle timer, and close()
// writes the former.
defer l.close();
l.sendFrame(req, req_payload) catch |e| {
std.debug.print("mux d {s}: quic: could not send the request: {s}\n", .{ verb, @errorName(e) });
return 1;
};
const got = l.awaitFrame(alloc, want, msLeft(deadline), .{}) catch |e| switch (e) {
error.Closed => null,
else => return e,
};
break :blk got orelse {
std.debug.print("mux d {s}: quic://{s} took the request and did not answer\n", .{ verb, q.host_port });
return 1;
};
},
};
defer frame.deinit(alloc);
try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload);
try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n");
return 0;
}
fn dump(alloc: std.mem.Allocator, target: AdminTarget, vt_mode: bool, session: []const u8) !u8 {
// vt byte ++ session-name tail, built by the wire module — empty is the
// wire's own default spelling, so a bare `mux d dump` sends exactly the
// one-byte payload that predates session names.
var buf: [proto.debug_dump_max_len]u8 = undefined;
const payload = proto.encodeDebugDumpNamed(&buf, vt_mode, session);
return oneShotQuery(alloc, target, "dump", .debug_dump, payload, .dump_reply);
}
fn stats(alloc: std.mem.Allocator, target: AdminTarget) !u8 {
return oneShotQuery(alloc, target, "stats", .stats_req, "", .stats_reply);
}
/// Request daemon shutdown, then wait for the peer process to exit rather than
/// only for socket unlink. Both an already-absent daemon and a completed stop
/// return zero, making the command idempotent for scripts.
///
/// Over QUIC there is no pid to wait on and no errno that says "absent", so
/// the verdict is the daemon's own goodbye: `Server.deinit` tears every
/// client slot down before it frees anything else, and a QUIC slot's
/// teardown is a CONNECTION_CLOSE to us. A port that never answered is a
/// refusal (rc 1), not a no-op — the silence may be a firewall or the wrong
/// key, and a script that read it as "already stopped" would be lied to.
/// The goodbye is read off `quic.Client.dead`, which a lost packet stream
/// or an idle expiry also sets, so the message says what was seen — the
/// connection ended after the request was delivered — and does not claim
/// the pid is gone; `mux d stats --quic` afterwards is the check.
fn stopCmd(alloc: std.mem.Allocator, target: AdminTarget) !u8 {
const sock_path = switch (target) {
.sock => |p| p,
.quic => |q| {
const deadline = adminDeadline();
var l = openAdminQuic(alloc, "stop", q, deadline) orelse return 1;
defer l.close();
l.sendFrame(.stop_req, "") catch |e| {
std.debug.print("mux d stop: quic: could not deliver the stop request: {s}\n", .{@errorName(e)});
return 1;
};
// Nothing is ever wanted: the loop ends on the daemon's close.
// `stop_req` as the wanted type is one the daemon never sends.
const got = l.awaitFrame(alloc, .stop_req, msLeft(deadline), .{}) catch |e| switch (e) {
error.Closed => {
std.debug.print(
"mux d: stopped (quic://{s} ended the connection after taking the request)\n",
.{q.host_port},
);
return 0;
},
else => return e,
};
if (got) |f| f.deinit(alloc);
std.debug.print(
"mux d stop: quic://{s} took the request and is still connected after {d}s\n",
.{ q.host_port, admin_quic_deadline_ms / 1000 },
);
return 1;
},
};
const stream = dial.dial(sock_path) catch {
std.debug.print("mux d stop: nothing listening on {s}\n", .{sock_path});
return 0;
};
// If the daemon exits between connect and write, the requested final state
// is already reached. Track whether the frame was delivered so later
// diagnostics do not point to logs for a request the daemon never received.
const asked = if (proto.writeFrame(stream.handle, .stop_req, "")) |_| true else |_| false;
const peer = peerPid(stream.handle);
stream.close();
// The pid is the verdict whenever the kernel names one. The path is
// not: a daemon that lost its path to a successor watches that path
// and re-binds it within a second of the successor's unlink
// (`Server.watchSockPath`), so "the socket still answers" can mean the
// OTHER daemon answered — and a stop that graded the path reported a
// clean exit as a failure. Process exit is also what a supervisor
// wants: the unlink happens before shell reaping and directory
// cleanup, so a stop that returned on the unlink returned early.
if (peer) |pid| return waitPidGone(alloc, pid, sock_path, asked);
// No pid (a kernel that does not expose the peer): the socket's
// silence is all there is. Probe before checking the deadline so the
// final interval is observed; a wedged event loop can still accept
// through the backlog, so silence here means the unlink ran.
const stop_deadline_ms: i64 = 2000;
const t0 = std.time.milliTimestamp();
while (true) {
if (!sockpath.answers(sock_path)) {
std.debug.print("mux d: stopped\n", .{});
return 0;
}
if (std.time.milliTimestamp() - t0 >= stop_deadline_ms) break;
std.Thread.sleep(50 * std.time.ns_per_ms);
}
return stillRunning(alloc, sock_path, asked, @divTrunc(stop_deadline_ms, 1000));
}
/// The peer's pid, or null when the kernel cannot expose it; callers then
/// rely on socket shutdown.
fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t {
const cred = server_os.peerCred(fd) orelse return null;
return cred.pid;
}
/// Wait for the daemon's process to leave. Only a live process answers
/// signal 0 with success: ESRCH is the answer wanted, and EPERM means the
/// pid was reused by someone else's process, so the daemon is just as gone.
fn waitPidGone(alloc: std.mem.Allocator, pid: std.posix.pid_t, sock_path: []const u8, asked: bool) u8 {
const gone_deadline_ms: i64 = 5000;
const t0 = std.time.milliTimestamp();
while (std.posix.kill(pid, 0)) |_| {
if (std.time.milliTimestamp() - t0 >= gone_deadline_ms) {
return stillRunning(alloc, sock_path, asked, @divTrunc(gone_deadline_ms, 1000));
}
std.Thread.sleep(20 * std.time.ns_per_ms);
} else |_| {}
std.debug.print("mux d: stopped\n", .{});
return 0;
}
/// The stop's failure line. Whether the request was delivered decides
/// whether the log is worth pointing at: a daemon that never read the
/// frame has nothing to say about it there.
fn stillRunning(alloc: std.mem.Allocator, sock_path: []const u8, asked: bool, secs: i64) u8 {
if (!asked) {
std.debug.print(
"mux d stop: could not deliver the stop request to {s}, and it is still running after {d}s\n",
.{ sock_path, secs },
);
return 1;
}
var hint: [log_hint_len]u8 = undefined;
std.debug.print(
"mux d stop: the daemon on {s} is still running after {d}s{s}\n",
.{ sock_path, secs, logHint(alloc, &hint) },
);
return 1;
}
/// What the remote-upgrade preflight learned. One ssh run answers everything:
/// `uname -m && command -v mux && mux d endpoint` — the bare endpoint verb,
/// which never starts a daemon — and the LINE COUNT is the diagnosis, because
/// `&&` stops at the first answerless step.
/// What the push's exit says about the candidate, by VALUE: the push word's
/// failure arm relays the remote word's true exit code, and the shell's
/// conventions make two of them diagnoses. 132 is 128+SIGILL — the candidate
/// EXECUTED and died on an instruction the remote CPU lacks, which for an
/// arch-checked ELF means a feature-level mismatch (a native build pushed at
/// a weaker x86_64). 126/127 are the shell saying it could not run the
/// candidate at all — a dynamic loader or libc that box does not have.
/// Everything else is the push not landing for reasons the remote's own
/// stderr (inherited) already narrated.
const PushVerdict = enum { landed, cpu_mismatch, cannot_load, failed };
fn pushVerdict(streamed: bool, term: std.process.Child.Term) PushVerdict {
if (!streamed) return .failed;
if (term != .Exited) return .failed;
return switch (term.Exited) {
0 => .landed,
132 => .cpu_mismatch,
126, 127 => .cannot_load,
else => .failed,
};
}
const Preflight = union(enum) {
/// The ssh ran but stdout was empty: no shell spoke, nothing to trust.
no_answer,
/// `command -v mux` found nothing. Nothing to overwrite; inventing an
/// install location for someone else's box is not this verb's call.
no_mux,
/// The remote's `uname -m`, verbatim, for a refusal line that shows both
/// spellings — we stream THIS machine's image, so the words must match.
bad_arch: []const u8,
/// mux is installed at `path`; `daemon_up` says whether the endpoint verb
/// answered, i.e. whether there is a daemon to trigger after the push.
ready: struct { path: []const u8, daemon_up: bool },
};
/// `mux d upgrade HOST`: put THIS image on `host` and upgrade the daemon
/// there — the remote spelling of the local verb, in the same three moves a
/// hand upgrade makes. Preflight (one ssh, read), push (stream the image
/// into an atomic rename over the installed mux), trigger (run the freshly
/// installed binary as `d upgrade`, which makes IT the candidate and leaves
/// the version rule, the manifest and the serving check to the machinery
/// that already owns them). All three sshs run in the foreground with the
/// user's own tty, like the entry dial: this is a dial the user asked for,
/// and ssh's prompts are part of it.
fn remoteUpgradeCmd(alloc: std.mem.Allocator, host: []const u8, allow_same: bool) !u8 {
// A refused write on a dead ssh must come back as BrokenPipe from the
// push loop below, not as SIGPIPE ending this process mid-report.
proxy.ignoreSigpipe();
const pf_argv = try handoff.upgradePreflightArgv(alloc, host);
defer handoff.freeArgv(alloc, pf_argv);
var pf = std.process.Child.init(pf_argv, alloc);
// stdin is EOF up front: a daemon's `endpoint` answer turns into a
// stdio proxy that pumps until its stdin closes, and the preflight only
// wants the announce line, not the pump.
pf.stdin_behavior = .Ignore;
pf.stdout_behavior = .Pipe;
// ssh's prompts and complaints go straight to the user; there is no
// wall on screen to protect during a `d` verb.
pf.stderr_behavior = .Inherit;
try pf.spawn();
// Drain to EOF even past the cap — a child blocked on a full pipe never
// exits, and wait() below would hold this process with it.
var pf_buf: [4096]u8 = undefined;
var pf_len: usize = 0;
while (true) {
var chunk: [1024]u8 = undefined;
const n = std.posix.read(pf.stdout.?.handle, &chunk) catch break;
if (n == 0) break;
const keep = @min(n, pf_buf.len - pf_len);
@memcpy(pf_buf[pf_len..][0..keep], chunk[0..keep]);
pf_len += keep;
}
const pf_term = try pf.wait();
// 255 is ssh's own exit code, distinct from any remote command's: the
// dial itself failed, and ssh has already said why on stderr.
if (pf_term == .Exited and pf_term.Exited == 255) {
std.debug.print("mux d upgrade: ssh to {s} failed\n", .{host});
return 1;
}
const local_arch = @tagName(builtin.cpu.arch);
const ready = switch (parsePreflight(pf_buf[0..pf_len], local_arch)) {
.no_answer => {
std.debug.print(
"mux d upgrade: {s}'s preflight answered nothing; not a box this can read\n",
.{host},
);
return 1;
},
.no_mux => {
std.debug.print(
"mux d upgrade: no mux on {s}'s PATH; install one there first — " ++
"a push only replaces an install, it does not invent one\n",
.{host},
);
return 1;
},
.bad_arch => |arch| {
std.debug.print(
"mux d upgrade: {s} is {s} and this image is {s}; refusing to push a binary that cannot run there\n",
.{ host, arch, local_arch },
);
return 1;
},
.ready => |r| r,
};
const push_argv = handoff.upgradePushArgv(alloc, host, ready.path) catch |e| switch (e) {
error.BadTargetPath => {
std.debug.print(
"mux d upgrade: {s} answered a mux path with a quote in it ({s}); not a box this will shell at\n",
.{ host, ready.path },
);
return 1;
},
error.OutOfMemory => return error.OutOfMemory,
};
defer handoff.freeArgv(alloc, push_argv);
var push = std.process.Child.init(push_argv, alloc);
push.stdin_behavior = .Pipe;
push.stdout_behavior = .Inherit;
push.stderr_behavior = .Inherit;
try push.spawn();
// The running image rather than a saved path: "push this binary" can
// only honestly mean the bytes executing here. How close the ask lands
// depends on the OS. On Linux `openSelfExe` opens the running INODE, so
// the bytes streamed are what is executing even after the file it was
// started from has been renamed over. On an OS that can only open by
// path, it streams whatever that path holds now — the same file in the
// ordinary case, and a replacement's bytes if something swapped the
// binary mid-push.
const streamed: bool = blk: {
var img = std.fs.openSelfExe(.{}) catch break :blk false;
defer img.close();
var buf: [64 * 1024]u8 = undefined;
while (true) {
const n = std.posix.read(img.handle, &buf) catch break :blk false;
if (n == 0) break :blk true;
var off: usize = 0;
while (off < n) {
off += std.posix.write(push.stdin.?.handle, buf[off..n]) catch break :blk false;
}
}
};
push.stdin.?.close();
push.stdin = null;
const push_term = try push.wait();
switch (pushVerdict(streamed, push_term)) {
.landed => {},
.cpu_mismatch => {
std.debug.print(
"mux d upgrade: {s}'s CPU cannot run this image — the candidate died on an " ++
"illegal instruction, so the arch match was not a feature match; nothing " ++
"was replaced. push a static release build instead\n",
.{host},
);
return 1;
},
.cannot_load => {
std.debug.print(
"mux d upgrade: {s} cannot load this image (no loader or libc for it there); " ++
"nothing was replaced\n",
.{host},
);
return 1;
},
.failed => {
std.debug.print("mux d upgrade: the push to {s} did not land; nothing was replaced\n", .{host});
return 1;
},
}
if (!ready.daemon_up) {
std.debug.print(
"mux d upgrade: installed {s} at {s}:{s}; no daemon running there to upgrade\n",
.{ build_options.version, host, ready.path },
);
return 0;
}
const trig_argv = try handoff.upgradeTriggerArgv(alloc, host, allow_same);
defer handoff.freeArgv(alloc, trig_argv);
var trig = std.process.Child.init(trig_argv, alloc);
// Fully inherited: the remote `mux d upgrade` reports in its own words —
// the daemon's refusal reasons included — and those words are the report.
trig.stdin_behavior = .Inherit;
trig.stdout_behavior = .Inherit;
trig.stderr_behavior = .Inherit;
try trig.spawn();
const trig_term = try trig.wait();
return switch (trig_term) {
.Exited => |code| code,
else => 1,
};
}
/// Slices into `out` — the caller keeps the buffer alive as long as the result.
/// Whether the box's `uname -m` word names the machine `want_arch` does.
///
/// The two sides spell one machine differently and neither is wrong: `uname -m`
/// on macOS prints `arm64`, and `want_arch` is `@tagName(builtin.cpu.arch)`,
/// which zig spells `aarch64` and has no `arm64` tag at all. Compared as bytes,
/// an Apple-silicon image refuses to push to an Apple-silicon box — the one
/// pairing where the check is certainly WRONG, since it is the same hardware on
/// both ends. `x86_64` is already the same word on every OS this runs on.
///
/// One direction only, and one pair only: `aarch64` is never what a kernel
/// prints here, so accepting it in the other direction would widen the map for
/// nothing, and every other mismatch is a real refusal.
fn archMatches(reported: []const u8, want_arch: []const u8) bool {
if (std.mem.eql(u8, reported, want_arch)) return true;
return std.mem.eql(u8, reported, "arm64") and std.mem.eql(u8, want_arch, "aarch64");
}
fn parsePreflight(out: []const u8, want_arch: []const u8) Preflight {
var lines = std.mem.splitScalar(u8, out, '\n');
const arch = lines.next() orelse "";
if (arch.len == 0) return .no_answer;
// The REPORTED word is what comes back on a refusal, so the message names
// the box's own spelling and the image's own spelling side by side.
if (!archMatches(arch, want_arch)) return .{ .bad_arch = arch };
const path = lines.next() orelse "";
if (path.len == 0) return .no_mux;
const announce = lines.next() orelse "";
return .{ .ready = .{ .path = path, .daemon_up = announce.len != 0 } };
}
/// Ask the daemon on `sock_path` to replace itself with the current binary. The
/// candidate supplies its version and path; the running daemon decides whether
/// the upgrade is allowed.
fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 {
var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
const exe = std.fs.selfExePath(&exe_buf) catch {
std.debug.print("mux d upgrade: cannot find own image\n", .{});
return 1;
};
var buf: [std.fs.max_path_bytes + 64]u8 = undefined;
const payload = proto.encodeUpgradeReq(&buf, .{
.allow_same_version = allow_same,
.version = build_options.version,
.path = exe,
}) catch {
std.debug.print("mux d upgrade: cannot name {s} in a request\n", .{exe});
return 1;
};
// Bounded, because a daemon older than this feature drops an unknown
// frame without a word: the expiry is a diagnosis, not a timeout. So is
// EOF — an older daemon that drops the connection over a frame it
// cannot read reaches the same conclusion silence does.
const reply = dial.ask(alloc, sock_path, .upgrade_req, payload, .upgrade_reply, 5000) catch |e| ask: {
switch (e) {
error.NoDaemon => {
std.debug.print("mux d upgrade: nothing listening on {s}\n", .{sock_path});
return 1;
},
error.RequestNotSent => {
std.debug.print("mux d upgrade: {s} closed before the request landed\n", .{sock_path});
return 1;
},
// Treat an unreadable reply like timeout or EOF: no usable upgrade
// response was received.
else => break :ask null,
}
};
// A frame with no status byte is not an answer — `parseUpgradeReply` says
// so — and joins timeout and EOF at the "no reply" line below.
if (reply) |frame| skip: {
defer frame.deinit(alloc);
const answer = proto.parseUpgradeReply(frame.payload) orelse break :skip;
if (!answer.ok) {
// The daemon's words, verbatim: it is the side that knows which
// check failed, and paraphrasing here would lose the versions.
std.debug.print("mux d upgrade: refused: {s}\n", .{answer.reason});
// Add compatibility context when version probing fails: v0.0.1-15
// and older expect `muxd <version>`, while this binary reports
// `mux <version>`.
if (std.mem.eql(u8, answer.reason, "version: output mismatch"))
std.debug.print(
"mux d upgrade: if that daemon is v0.0.1-15 or older, it wants a " ++
"candidate that prints `muxd <version>` and this one is `mux`. " ++
"There is no in-place path across that rename: `mux d stop` then " ++
"`mux d start -d`, once.\n",
.{},
);
return 1;
}
std.debug.print("mux d: upgraded to {s}\n", .{build_options.version});
return confirmServing(alloc, sock_path);
}
std.debug.print(
"mux d upgrade: no reply: this daemon predates upgrade — stop and run\n",
.{},
);
return 1;
}
/// Verify that the upgraded daemon, rather than merely the inherited listening
/// socket, is serving protocol requests.
fn confirmServing(alloc: std.mem.Allocator, sock_path: []const u8) u8 {
// A connect is insufficient because the listener descriptor survives exec
// and can queue connections throughout handover. A valid reply proves that
// the new image is accepting and processing frames.
const deadline_ms: u32 = 5000;
if (dial.ask(alloc, sock_path, .stats_req, "", .stats_reply, deadline_ms) catch |e| switch (e) {
error.NoDaemon => {
std.debug.print("mux d upgrade: {s} stopped answering after the exec\n", .{sock_path});
return 1;
},
else => return 1,
}) |frame| {
frame.deinit(alloc);
return 0;
}
const secs = deadline_ms / 1000;
var hint: [log_hint_len]u8 = undefined;
std.debug.print(
"mux d upgrade: exec'd, but {s} has not answered in {d}s{s}\n",
.{ sock_path, secs, logHint(alloc, &hint) },
);
return 1;
}
const log_hint_len = std.fs.max_path_bytes + 64;
/// The "where the rest of the story is" clause, or "" when there is no path to
/// name — these lines are read by someone who is not at that box. Only when
/// the path resolves, so an absent HOME does not replace the finding with a
/// trace; and the hedge stays in the words, since a foreground start logs to stderr.
fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 {
const log = xdg.logPath(alloc) catch return "";
defer alloc.free(log);
// A path too long for the buffer is dropped rather than clipped: half
// a path is worse than none, and `log_hint_len` clears PATH_MAX, so
// the only paths it drops are ones nothing could have opened anyway.
return std.fmt.bufPrint(
buf,
" (if it was started detached, its log is {s})",
.{log},
) catch "";
}
/// Run `mux d proxy` after emitting one endpoint announcement line. Direct
/// attaches use `--start` to ensure a daemon exists; background wall polls omit
/// it and remain read-only.
///
/// The announcement is mandatory because the client blocks until it reads one
/// newline-terminated line. Silence would hang the SSH attachment.
///
/// Recoverable QUIC failures announce `endpoint none` and continue over SSH.
/// Failures that also prevent proxying exit and let the client observe EOF;
/// they must not announce a working fallback first.
///
/// Stdout contains only the announcement and protocol frames. Diagnostics go to
/// stderr, which SSH forwards separately.
fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posix.fd_t, start: bool) !u8 {
// `--start` ensures a daemon before querying it. Forward the same explicit
// socket path so startup cannot bind the default socket while this command
// probes another path.
//
// A failed spawn has already said so on stderr, so returning here leaves
// the announce UNWRITTEN — the "no daemon" shape the client reads.
if (start) {
const sock_z = try alloc.dupeZ(u8, sock_path);
defer alloc.free(sock_z);
const run_args = [_][:0]const u8{ "--sock", sock_z };
if (startDetached(alloc, &run_args, sock_path, "mux d endpoint") == null) return 1;
}
// Silent otherwise: the wall runs this over ssh once a second per host, so
// a word on stderr here is a word on the wall's alternate screen.
if (!sockpath.answers(sock_path)) return 1;
// The announcement and proxy share stdout. Install the proxy's SIGPIPE
// handling before either writes so a closed pipe returns EPIPE instead of
// terminating the process.
proxy.ignoreSigpipe();
// Resolve or create the key before asking for the endpoint. The daemon's
// lazy QUIC bind uses the default path only when the file already exists.
const key = announceKey(alloc);
const port: u16 = if (key == null) 0 else askEndpointPort(alloc, sock_path);
var line_buf: [handoff.announce_max_len]u8 = undefined;
const line: []const u8 = blk: {
const k = key orelse break :blk handoff.announce_none;
// Endpoint reply zero means no listener. Convert it to the explicit
// negative announcement before formatting, which intentionally rejects
// port zero.
if (port == 0) {
reportNoListener(alloc, sock_path);
break :blk handoff.announce_none;
}
// Fall back to SSH instead of panicking if announcement formatting ever
// fails; losing QUIC is safer than terminating the pending session.
break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch
handoff.announce_none;
};
proto.writeAllFd(out_fd, line) catch |err| {
// The proxy also requires stdout, so a failed announcement leaves no
// usable fallback stream. Report the actual write error.
std.debug.print(
"mux d endpoint: cannot write the announce to stdout: {s}\n",
.{@errorName(err)},
);
return 1;
};
return proxy.run(sock_path);
}
/// Report that the daemon is reachable but has no QUIC listener. The detailed
/// reason is in the remote daemon log, so include its path when available.
fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void {
var hint: [log_hint_len]u8 = undefined;
std.debug.print(
"mux d endpoint: the daemon on {s} produced no QUIC listener; staying on ssh{s}\n",
.{ sock_path, logHint(alloc, &hint) },
);
}
/// Resolve the key announced by `mux d endpoint`. Return null after printing one
/// diagnostic when no usable key is available.
fn announceKey(alloc: std.mem.Allocator) ?quic.Key {
// Resolve the default path up front so a missing HOME can be distinguished
// from failures involving an actual path.
const dflt: ?[]const u8 = xdg.keyPath(alloc) catch null;
defer if (dflt) |p| alloc.free(p);
switch (announceKeyFrom(xdg.pickKey(null, std.posix.getenv(xdg.key_env)), dflt)) {
.key => |k| return k,
.no_path => std.debug.print(
"mux d endpoint: no HOME to resolve a key path; staying on ssh\n",
.{},
),
.create_failed => |f| std.debug.print(
"mux d endpoint: cannot create {s}: {s}; staying on ssh\n",
.{ f.path, @errorName(f.err) },
),
.load_failed => |f| reportKeyRefusal(f.path, f.err),
}
return null;
}
/// Result of selecting, creating, and loading the endpoint key. Keeping this
/// decision separate from diagnostics makes precedence and failures testable.
const KeyResolution = union(enum) {
key: quic.Key,
/// Neither `MUX_KEY_FILE` nor a HOME-based default path is available.
no_path,
/// The default key was absent and could not be created. Kept apart
/// from `load_failed` because it is the cause and the load's
/// `KeyFileMissing` would only be its symptom.
create_failed: struct { path: []const u8, err: anyerror },
load_failed: struct { path: []const u8, err: anyerror },
};
/// Load `MUX_KEY_FILE` when supplied, otherwise load or create the default key.
/// This matches daemon lazy-bind precedence so the announced key authenticates
/// against the listener that daemon creates.
///
/// Only the default is created when absent. An explicit environment path is
/// user-managed and must already exist. Inputs are injected and this function
/// prints nothing so each branch can be tested directly.
fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResolution {
if (env) |p| return if (quic.Key.load(p)) |k|
.{ .key = k }
else |err|
.{ .load_failed = .{ .path = p, .err = err } };
const path = dflt orelse return .no_path;
// Ignore `KeyExists` and load the existing key. Preserve other creation
// failures because they identify causes such as an unwritable directory
// more accurately than the subsequent missing-file load error.
const create_failed: ?anyerror = if (xdg.writeNewKey(path)) |_|
null
else |err| if (err == error.KeyExists) null else err;
return if (quic.Key.load(path)) |k|
.{ .key = k }
else |load_err| if (create_failed) |err|
.{ .create_failed = .{ .path = path, .err = err } }
else
.{ .load_failed = .{ .path = path, .err = load_err } };
}
/// Print a key-loading diagnostic suitable for forwarding over SSH. Reuse
/// `quic.keyRefusalBody` so every CLI path reports the same reason.
fn reportKeyRefusal(path: []const u8, err: anyerror) void {
var buf: [quic.key_refusal_len]u8 = undefined;
std.debug.print(
"mux d endpoint: {s}; staying on ssh\n",
.{quic.keyRefusalBody(&buf, err, path)},
);
}
/// Request the daemon's QUIC port with a bounded wait. Return zero for any
/// failure so old daemons that ignore the request fall back to SSH instead of
/// hanging.
fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 {
const frame = (dial.ask(
alloc,
sock_path,
.endpoint_req,
"",
.endpoint_reply,
start_deadline_ms,
) catch null) orelse return 0;
defer frame.deinit(alloc);
return proto.decodeEndpointReply(frame.payload) catch 0;
}
/// Run `mux d start` in the current process or spawn it for `-d`. Forward every
/// argument after `start` unchanged so foreground and detached parsing match.
fn startCmd(alloc: std.mem.Allocator, o: DaemonArguments, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 {
if (!o._detach) return run(alloc, o, sock_path);
// Remove `-d` from child argv to prevent recursive detached spawning.
const r = startDetached(alloc, forwarded[1..], sock_path, "mux d") orelse return 1;
if (r == .already_running) {
std.debug.print(
"mux d: already running on {s} (stop it first with `mux d stop --sock {s}` if you meant different flags)\n",
.{ sock_path, sock_path },
);
}
return 0;
}
/// Time allowed for a spawned daemon to begin answering on its socket.
const start_deadline_ms: u32 = 2000;
const StartOutcome = enum { already_running, started };
const StartError = error{ SpawnFailed, NeverAnswered };
/// Destination and formatting policy for detached-start progress. Callers set
/// the command prefix and whether a TTY receives animated dots. An already
/// running daemon produces no progress output.
const StartProgress = struct {
fd: std.posix.fd_t,
prefix: []const u8,
tty: bool,
fn emit(self: StartProgress, s: []const u8) void {
_ = std.posix.write(self.fd, s) catch {};
}
fn emitFmt(self: StartProgress, comptime fmt: []const u8, args: anytype) void {
var buf: [256]u8 = undefined;
const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
self.emit(s);
}
};
/// Test-only pid of the most recent spawn, used to reap deliberately persistent
/// stub processes. Callers are single-threaded, so synchronization is omitted.
var last_spawned_pid: std.posix.pid_t = 0;
/// Implement detached startup: probe the socket, fork, exec this binary, and
/// poll until the daemon answers or the deadline expires.
fn forkDaemon(
alloc: std.mem.Allocator,
exe_path: []const u8,
run_args: []const [:0]const u8,
sock_path: []const u8,
progress: StartProgress,
deadline_ms: u32,
log_path_override: ?[]const u8,
) StartError!StartOutcome {
// `NeverAnswered` leaves the child running because it may finish startup
// after the caller's deadline and be available on retry.
if (sockpath.answers(sock_path)) return .already_running;
std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed;
// Owned either way, so one `free` covers both: a caller-supplied path is
// duped rather than borrowed, because the xdg branch must allocate.
const log_path = if (log_path_override) |p|
alloc.dupe(u8, p) catch return error.SpawnFailed
else
xdg.logPath(alloc) catch return error.SpawnFailed;
defer alloc.free(log_path);
if (std.fs.path.dirname(log_path)) |dir|
std.fs.cwd().makePath(dir) catch return error.SpawnFailed;
// Always open with O_APPEND. One XDG log serves every daemon socket on the
// machine, so truncation or writing from offset zero could destroy another
// daemon's log.
const log: std.fs.File = .{
.handle = std.posix.open(log_path, .{
.ACCMODE = .WRONLY,
.CREAT = true,
.APPEND = true,
// Set CLOEXEC explicitly because the raw `posix.open` API does not.
// The descriptor must not propagate through the daemon into session
// shells; dup2 intentionally clears CLOEXEC for stdout and stderr.
.CLOEXEC = true,
}, 0o600) catch return error.SpawnFailed,
};
defer log.close();
const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed;
defer devnull.close();
// Build null-terminated child argv as `mux d start <forwarded...>`. Keeping
// the mode word makes the long-lived daemon identifiable in process lists.
const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed;
defer alloc.free(exe_z);
const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 3, null) catch
return error.SpawnFailed;
defer alloc.free(argv);
argv[0] = "mux";
argv[1] = "d";
argv[2] = "start";
for (run_args, 0..) |a, i| argv[i + 3] = a.ptr;
progress.emitFmt("{s}: starting\u{2026}", .{progress.prefix});
if (!progress.tty) progress.emit("\n");
const t0 = std.time.milliTimestamp();
// The fork itself is `server_os.forkDetached`; this function owns what
// goes INTO it — the log, the argv and the deadline — and folder rule 6
// names that file as the one fork.
const pid = server_os.forkDetached(exe_z.ptr, argv.ptr, devnull.handle, log.handle) catch {
if (progress.tty) progress.emit("\n");
return error.SpawnFailed;
};
last_spawned_pid = pid;
// Parent: poll the socket. Animate dots only on a TTY so scripted output is
// stable.
var next_dot: i64 = t0 + 250;
// A pid owes us exactly one reap: a second `waitpid` gets ECHILD, which
// `std.posix.waitpid` answers with `unreachable`. That panic would land
// precisely on the path that exists to report a child that died young.
var reaped = false;
while (true) {
if (sockpath.answers(sock_path)) {
const secs = @as(f64, @floatFromInt(std.time.milliTimestamp() - t0)) / 1000.0;
// The leading space exists to follow the dots on a tty. There
// are no dots on a non-tty — the ssh proxy's case, and now the
// common one — where it would only be a stray space at the
// start of a scripted line.
if (progress.tty) progress.emit(" ");
progress.emitFmt("up ({d:.1}s) pid={d}\n", .{ secs, pid });
return .started;
}
const now = std.time.milliTimestamp();
if (now - t0 >= deadline_ms) {
// The newline terminates the dot line, so it belongs to the
// same condition the dots do: on a non-tty there are no dots
// and it would only put a blank line into scripted output.
if (progress.tty) progress.emit("\n");
// "daemon" in the body, because the prefix is already the
// program speaking: `mux d: daemon did not answer` reads
// correctly and would not survive naming the binary twice.
progress.emitFmt(
"{s}: daemon did not answer within {d}s \u{2014} log: {s}\n",
.{ progress.prefix, deadline_ms / 1000, log_path },
);
return error.NeverAnswered;
}
if (progress.tty and now >= next_dot) {
progress.emit(".");
next_dot = now + 250;
}
// Reap an exited child once, including a start-race loser or a child
// with invalid flags. Repeated waitpid calls after reaping would fail.
if (!reaped and std.posix.waitpid(pid, std.posix.W.NOHANG).pid == pid)
reaped = true;
std.Thread.sleep(50 * std.time.ns_per_ms);
}
}
/// Start a detached daemon for either `-d` or `endpoint --start` using the
/// current executable and shared deadline. Return null after reporting failure.
fn startDetached(alloc: std.mem.Allocator, run_args: []const [:0]const u8, sock_path: []const u8, prefix: []const u8) ?StartOutcome {
var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
const exe = spawn.selfExe(&exe_buf);
const progress: StartProgress = .{
.fd = std.posix.STDERR_FILENO,
.prefix = prefix,
.tty = std.posix.isatty(std.posix.STDERR_FILENO),
};
return forkDaemon(alloc, exe, run_args, sock_path, progress, start_deadline_ms, null) catch |err| {
// `NeverAnswered`'s failure line was already printed by StartProgress. A
// spawn that never happened has nothing printed yet, and
// `std.debug.print` rather than `emitFmt` because an exe path can run to
// `max_path_bytes` and a fixed buffer would drop the whole line.
if (err == error.SpawnFailed)
std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, exe, @errorName(err) });
return null;
};
}
fn keygen(alloc: std.mem.Allocator) !u8 {
const path = try xdg.keyPath(alloc);
defer alloc.free(path);
xdg.writeNewKey(path) catch |err| switch (err) {
error.KeyExists => {
std.debug.print(
"mux d keygen: {s} already exists; rotation is `rm` + `keygen`, deliberately\n",
.{path},
);
return 1;
},
else => |e| return e,
};
var buf: [std.fs.max_path_bytes + 1]u8 = undefined;
const line = std.fmt.bufPrint(&buf, "{s}\n", .{path}) catch unreachable;
_ = std.posix.write(std.posix.STDOUT_FILENO, line) catch {};
return 0;
}
// ---------------------------------------------------------------------------
// Tests. These run only because `exe_mod` is in build.zig's test loop: a test
// written here without it compiles and silently never executes.
// ---------------------------------------------------------------------------
/// The tests must speak argsAlloc's type: a slice of
/// sentinel-terminated strings.
fn parse(comptime argv: []const [:0]const u8) DaemonInvocation {
return parseArgs(argv);
}
/// `specs` holds plain slices; a table-driven test needs argsAlloc's type.
fn nameZ(comptime name: []const u8) [:0]const u8 {
const buf = name ++ "\x00";
return buf[0..name.len :0];
}
test "parseArgs: subcommands and their existing flags" {
const r = parse(&.{ "d", "start" });
try std.testing.expect(r == .command);
try std.testing.expect(r.command._cmd == .start);
try std.testing.expect(r.command.sock == null);
try std.testing.expectEqual(@as(u16, 80), r.command.cols);
try std.testing.expectEqual(@as(u16, 24), r.command.rows);
const d = parse(&.{ "d", "dump", "--vt", "--sock", "/tmp/x.sock" });
try std.testing.expect(d.command._cmd == .dump);
try std.testing.expect(d.command.vt);
try std.testing.expectEqualStrings("/tmp/x.sock", d.command.sock.?);
// No --session named: nothing to validate, and `dump` spells the absence
// on the wire as the empty tail.
try std.testing.expect(d.command.session == null);
const g = parse(&.{ "d", "start", "--cols", "120", "--rows", "40", "--shell", "/bin/dash" });
try std.testing.expectEqual(@as(u16, 120), g.command.cols);
try std.testing.expectEqual(@as(u16, 40), g.command.rows);
try std.testing.expectEqualStrings("/bin/dash", g.command.shell.?);
try std.testing.expect(parse(&.{"d"}).usage == .no_command);
try std.testing.expect(parse(&.{ "d", "wat" }).usage == .unknown_command);
try std.testing.expect(parse(&.{ "d", "start", "--wat" }).usage == .unknown_arg);
}
test "pushVerdict: the relayed exit code names the mismatch uname -m cannot see" {
const T = std.process.Child.Term;
// 0 with the stream complete: the rename happened.
try std.testing.expect(pushVerdict(true, T{ .Exited = 0 }) == .landed);
// 132 is the shell's 128+SIGILL: a right-arch ELF whose instructions the
// remote CPU lacks — the native-build push that bricked a live box
// (2026-09-01) and the case the preflight's arch check cannot catch.
try std.testing.expect(pushVerdict(true, T{ .Exited = 132 }) == .cpu_mismatch);
// 126 and 127 are the shell's own cannot-execute codes: the candidate is
// on disk but nothing there can load it — a dynamic interpreter or libc
// the box does not have.
try std.testing.expect(pushVerdict(true, T{ .Exited = 126 }) == .cannot_load);
try std.testing.expect(pushVerdict(true, T{ .Exited = 127 }) == .cannot_load);
// Everything else is the push not landing: ssh's own 255, a full disk's
// 1, a killed ssh — and a clean exit whose stream broke midway, which is
// a lying remote rather than a landed push.
try std.testing.expect(pushVerdict(true, T{ .Exited = 255 }) == .failed);
try std.testing.expect(pushVerdict(true, T{ .Signal = 15 }) == .failed);
try std.testing.expect(pushVerdict(false, T{ .Exited = 0 }) == .failed);
}
test "parsePreflight: the line count is the diagnosis" {
// Three lines: arch, the installed path, an endpoint announce — a daemon
// is up and the full push-then-upgrade flow applies.
const up = parsePreflight("x86_64\n/home/u/.local/bin/mux\nquic 1.2.3.4:1 k\n", "x86_64");
try std.testing.expectEqualStrings("/home/u/.local/bin/mux", up.ready.path);
try std.testing.expect(up.ready.daemon_up);
// Two lines: mux is installed but `mux d endpoint` answered nothing —
// push the image, skip the trigger.
const idle = parsePreflight("x86_64\n/usr/local/bin/mux\n", "x86_64");
try std.testing.expectEqualStrings("/usr/local/bin/mux", idle.ready.path);
try std.testing.expect(!idle.ready.daemon_up);
// One line: `command -v mux` found nothing — there is nothing to
// overwrite, and inventing an install location is not this verb's call.
try std.testing.expect(parsePreflight("x86_64\n", "x86_64") == .no_mux);
// The remote's word comes back for the refusal line, so the user reads
// both spellings rather than a bare "mismatch".
const arm = parsePreflight("aarch64\n/usr/bin/mux\n", "x86_64");
try std.testing.expectEqualStrings("aarch64", arm.bad_arch);
// Nothing at all is its own answer: the ssh ran but no shell spoke.
try std.testing.expect(parsePreflight("", "x86_64") == .no_answer);
try std.testing.expect(parsePreflight("\n", "x86_64") == .no_answer);
}
test "parsePreflight: a Mac's arm64 is the aarch64 this image is built for" {
// The pairing this exists for: an Apple-silicon image pushing to an
// Apple-silicon box. `uname -m` says `arm64`, `@tagName(builtin.cpu.arch)`
// says `aarch64`, and a byte compare refused the one push that is
// certainly safe. There is one Mac in the fixture and `mux d upgrade`
// needs two, so `make xos` cannot reach this and the unit test is the pin.
const mac = parsePreflight("arm64\n/Users/u/.local/bin/mux\nquic 1.2.3.4:1 k\n", "aarch64");
try std.testing.expectEqualStrings("/Users/u/.local/bin/mux", mac.ready.path);
try std.testing.expect(mac.ready.daemon_up);
// Neither half of the map loosens a real mismatch, and both refusals still
// carry the BOX's spelling — test/xos.sh's upgrade-refused leg reads both
// of these sentences off the wire in the two directions it pushes.
const mac_from_intel = parsePreflight("arm64\n/Users/u/.local/bin/mux\n", "x86_64");
try std.testing.expectEqualStrings("arm64", mac_from_intel.bad_arch);
const intel_from_mac = parsePreflight("x86_64\n/home/u/.local/bin/mux\n", "aarch64");
try std.testing.expectEqualStrings("x86_64", intel_from_mac.bad_arch);
// The map is one direction: a box that reports the zig tag is not a box
// this has ever met, and it is not what makes an x86_64 image acceptable.
try std.testing.expect(!archMatches("aarch64", "x86_64"));
try std.testing.expect(archMatches("aarch64", "aarch64"));
}
test "parseArgs: upgrade takes one HOST word, and only upgrade does" {
const r = parse(&.{ "d", "upgrade", "box" });
try std.testing.expect(r == .command);
try std.testing.expect(r.command._cmd == .upgrade);
try std.testing.expectEqualStrings("box", r.command._host.?);
// The flag still rides beside the word, in either order.
const f = parse(&.{ "d", "upgrade", "--allow-same-version", "box" });
try std.testing.expectEqualStrings("box", f.command._host.?);
try std.testing.expect(f.command.allow_same_version);
// No word means the local daemon, exactly as before.
try std.testing.expect(parse(&.{ "d", "upgrade" }).command._host == null);
// A second word is a mistake, not a second host.
try std.testing.expect(parse(&.{ "d", "upgrade", "box", "box2" }).usage == .unknown_arg);
// Every other verb still refuses a bare word: a host on `stop` would be
// a remote stop nobody designed.
try std.testing.expect(parse(&.{ "d", "stop", "box" }).usage == .unknown_arg);
// --sock names a LOCAL socket and HOST names another box; honoring one
// and dropping the other silently would leave the user believing both.
try std.testing.expect(parse(&.{ "d", "upgrade", "box", "--sock", "/x" }).usage == .sock_with_host);
}
test "parse: dump --session rides into the payload" {
const d = parse(&.{ "d", "dump", "--session", "b", "--sock", "/tmp/x.sock" });
try std.testing.expect(d == .command);
try std.testing.expectEqualStrings("b", d.command.session.?.name);
// Reject an unaddressable session name before encoding it. The parse result
// identifies the flag whose value failed validation.
const bad = parse(&.{ "d", "dump", "--session", "has space" });
try std.testing.expect(bad.usage == .bad_value);
try std.testing.expectEqualStrings("--session", bad.usage.bad_value);
}
test "parseArgs: --key without --quic is refused; --quic alone defers to main" {
const both = parse(&.{ "d", "start", "--quic", "0.0.0.0:4433", "--key", "/k" });
try std.testing.expect(both == .command);
try std.testing.expectEqualStrings("0.0.0.0:4433", both.command.quic.?);
try std.testing.expectEqualStrings("/k", both.command.key.?);
// --quic without --key is no longer a parse error: main resolves
// MUX_KEY_FILE and the default path, and parse cannot see either.
const deferred = parse(&.{ "d", "start", "--quic", "0.0.0.0:4433" });
try std.testing.expect(deferred == .command);
try std.testing.expect(deferred.command.key == null);
// A key with nowhere to listen is still a mistake with no reading that
// makes it sensible, and parse can see the whole of it.
try std.testing.expect(parse(&.{ "d", "start", "--key", "/k" }).usage == .key_without_quic);
// Neither is the ordinary case and must stay silent.
const neither = parse(&.{ "d", "start" });
try std.testing.expect(neither.command.quic == null);
try std.testing.expect(neither.command.key == null);
}
test "parseArgs: --quic-idle-ms defaults, parses, and rejects invalid values" {
const dflt = parse(&.{ "d", "start", "--quic", "127.0.0.1:1", "--key", "/k" });
// Compare with the documented literal so this test detects an accidental
// change to the source default.
try std.testing.expectEqual(@as(u32, 15_000), dflt.command.quic_idle_ms.ms);
const set = parse(&.{ "d", "start", "--quic", "127.0.0.1:1", "--key", "/k", "--quic-idle-ms", "2500" });
try std.testing.expectEqual(@as(u32, 2500), set.command.quic_idle_ms.ms);
// ngtcp2 interprets zero as no timeout, which would invert the user's
// request for an immediate timeout, so reject it.
try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "0" }).usage == .bad_value);
try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "soon" }).usage == .bad_value);
try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "-5" }).usage == .bad_value);
// Reject values wider than u32 before converting milliseconds to
// nanoseconds.
try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "99999999999" }).usage == .bad_value);
// The idle flag alone does not turn QUIC on, and must not smuggle the
// both-or-neither rule past the check.
try std.testing.expect(parse(&.{ "d", "start", "--quic-idle-ms", "2500" }) == .command);
// Same treatment for the numbers that were already here.
try std.testing.expect(parse(&.{ "d", "start", "--cols", "wide" }).usage == .bad_value);
try std.testing.expect(parse(&.{ "d", "start", "--rows", "99999" }).usage == .bad_value);
}
test "parseArgs: a value-taking flag at the end of argv names itself" {
// This used to report "unknown argument: --quic", which blames the flag
// rather than the missing value.
inline for (.{ "--sock", "--shell", "--cols", "--rows", "--quic", "--key", "--quic-idle-ms", "--session", "--resume-fd", "--resume-fail-at" }) |flag| {
const r = parse(&.{ "d", "start", flag });
try std.testing.expect(r.usage == .missing_value);
try std.testing.expectEqualStrings(flag, r.usage.missing_value);
}
}
// The table cannot verify hand-written usage text by itself. Anchor each check
// to the command position so incidental mentions, such as "proxy" in another
// description, do not count as documentation.
test "usage names every subcommand" {
inline for (specs) |s| {
const named = std.mem.indexOf(u8, usage, "\n mux d " ++ s.name) != null;
// expect() alone would print only "expected true", which does not
// say which verb went missing.
if (!named) std.debug.print("usage never names the subcommand `{s}`\n", .{s.name});
try std.testing.expect(named);
}
}
test "parseBindAddr: a hostname is rejected rather than resolved" {
const a = try parseBindAddr("127.0.0.1:4433");
try std.testing.expectEqual(@as(u16, 4433), a.getPort());
const six = try parseBindAddr("[::1]:4433");
try std.testing.expectEqual(@as(u16, 4433), six.getPort());
try std.testing.expect(six.any.family == std.posix.AF.INET6);
// Bind configuration requires one numeric address, not a hostname that may
// resolve to several addresses.
try std.testing.expect(std.meta.isError(parseBindAddr("localhost:4433")));
}
test "keygen: a generated key loads through quic.Key.load" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [128]u8 = undefined;
const path = try std.fmt.bufPrint(&buf, "{s}/key", .{tmp.path()});
try xdg.writeNewKey(path);
_ = try quic.Key.load(path);
}
test "parseArgs: --version is a command, not a flag on one" {
const r = parse(&.{ "d", "--version" });
try std.testing.expect(r == .command);
try std.testing.expect(r.command._cmd == .version);
// Version takes precedence when attached to another command. Test parsing
// directly because `main` would write to the test runner's stdout channel.
const on_start = parse(&.{ "d", "start", "--sock", "/x", "--version" });
try std.testing.expect(on_start == .command);
try std.testing.expect(on_start.command._cmd == .version);
}
test "parseArgs: --help is a command, and a flag on one, and both exit 0 on stdout" {
const bare = parse(&.{ "d", "--help" });
try std.testing.expect(bare == .command);
try std.testing.expect(bare.command._cmd == .help);
// On a subcommand, help comes from flag parsing and takes precedence over
// adjacent syntax such as a missing `--sock` value.
try std.testing.expect(parse(&.{ "d", "start", "--help" }).usage == .help);
try std.testing.expect(parse(&.{ "d", "dump", "-h", "--sock", "/x" }).usage == .help);
try std.testing.expect(parse(&.{ "d", "start", "--sock", "--help" }).usage == .help);
// Use `usageCode` because `usageExit` writes to the test runner's stdout
// protocol channel.
try std.testing.expectEqual(@as(u8, 0), usageCode(.help));
try std.testing.expectEqual(@as(u8, 2), usageCode(.no_command));
}
test "parseArgs: keygen takes no flags" {
const r = parse(&.{ "d", "keygen" });
try std.testing.expect(r == .command);
try std.testing.expect(r.command._cmd == .keygen);
try std.testing.expect(parse(&.{ "d", "keygen", "--sock", "/x" }).usage == .unknown_arg);
}
test "parseArgs: -d is a word in `start`'s second slot, and everything after it is the daemon's" {
const r = parse(&.{ "d", "start", "--sock", "/tmp/x.sock", "--cols", "100" });
try std.testing.expect(r == .command);
try std.testing.expect(r.command._cmd == .start);
try std.testing.expectEqualStrings("/tmp/x.sock", r.command.sock.?);
try std.testing.expectEqual(@as(u16, 100), r.command.cols);
// Foreground mode is the default so startup failures remain visible in the
// invoking shell.
try std.testing.expect(!r.command._detach);
// Remove only the detach word; forward the remaining argv unchanged. The
// e2e test verifies the child command line through `/proc/PID/cmdline`.
const d = parse(&.{ "d", "start", "-d", "--sock", "/tmp/x.sock" });
try std.testing.expect(d.command._detach);
try std.testing.expectEqualStrings("/tmp/x.sock", d.command.sock.?);
try std.testing.expect(parse(&.{ "d", "start", "--detach", "--cols", "100" }).command._detach);
// A second `-d` is unknown rather than silently removed.
const twice = parse(&.{ "d", "start", "-d", "-d" });
try std.testing.expectEqualStrings("-d", twice.usage.unknown_arg);
// Detach is positional and is not recognized after other start arguments.
const late = parse(&.{ "d", "start", "--sock", "/tmp/x.sock", "-d" });
try std.testing.expect(late == .usage);
try std.testing.expectEqualStrings("-d", late.usage.unknown_arg);
try std.testing.expectEqual(@as(u8, 2), usageCode(late.usage));
// A token consumed as a value is never reinterpreted as a flag.
const shell = parse(&.{ "d", "start", "-d", "--shell", "-d" });
try std.testing.expect(shell.command._detach);
try std.testing.expectEqualStrings("-d", shell.command.shell.?);
}
test "parseArgs: stop is a command and takes --sock" {
const r = parse(&.{ "d", "stop" });
try std.testing.expect(r == .command);
try std.testing.expect(r.command._cmd == .stop);
try std.testing.expect(r.command.sock == null);
const s = parse(&.{ "d", "stop", "--sock", "/tmp/x.sock" });
try std.testing.expect(s.command._cmd == .stop);
try std.testing.expectEqualStrings("/tmp/x.sock", s.command.sock.?);
}
test "parseArgs: stop, dump and stats take --quic as the other door, and never both doors" {
// The path-less daemon's door (145807a2): the same `--quic HOST:PORT
// [--key]` mux a spells, on the three verbs the daemon serves to a
// QUIC client slot.
const q = parse(&.{ "d", "stop", "--quic", "127.0.0.1:4433", "--key", "/k" });
try std.testing.expect(q == .command);
try std.testing.expect(q.command._cmd == .stop);
try std.testing.expectEqualStrings("127.0.0.1:4433", q.command.quic.?);
try std.testing.expectEqualStrings("/k", q.command.key.?);
try std.testing.expect(parse(&.{ "d", "stats", "--quic", "box" }) == .command);
try std.testing.expect(parse(&.{ "d", "dump", "--session", "a", "--quic", "box:1" }) == .command);
// Two daemons named for one question is a refusal, not a preference.
try std.testing.expect(parse(&.{ "d", "stop", "--sock", "/x", "--quic", "box" }).usage == .sock_with_quic);
try std.testing.expect(parse(&.{ "d", "stats", "--quic", "box", "--sock", "/x" }).usage == .sock_with_quic);
// `start` listens on both, so both stay legal there.
try std.testing.expect(parse(&.{ "d", "start", "--sock", "/x", "--quic", "0.0.0.0:1" }) == .command);
// And the key rule is unchanged: --key still wants --quic.
try std.testing.expect(parse(&.{ "d", "stop", "--key", "/k" }).usage == .key_without_quic);
// A verb with no QUIC door refuses the flag rather than dropping it:
// `upgrade --quic HOST` used to upgrade the socket's daemon in silence.
inline for (.{ "upgrade", "proxy", "endpoint" }) |verb| {
const r = parse(&.{ "d", verb, "--quic", "box" });
try std.testing.expect(r == .usage);
try std.testing.expectEqualStrings("--quic", r.usage.unknown_arg);
}
}
test "parseArgs: endpoint is a command and takes --sock" {
const r = parse(&.{ "d", "endpoint" });
try std.testing.expect(r == .command);
try std.testing.expect(r.command._cmd == .endpoint);
try std.testing.expect(r.command.sock == null);
const s = parse(&.{ "d", "endpoint", "--sock", "/tmp/x.sock" });
try std.testing.expect(s.command._cmd == .endpoint);
try std.testing.expectEqualStrings("/tmp/x.sock", s.command.sock.?);
// Unknown flags fail, but shared value-taking flags still parse for this
// command and are ignored. Only `keygen` rejects all trailing arguments.
try std.testing.expect(parse(&.{ "d", "endpoint", "--quiet" }).usage == .unknown_arg);
try std.testing.expect(parse(&.{ "d", "endpoint", "--cols", "100" }) == .command);
const missing = parse(&.{ "d", "endpoint", "--sock" });
try std.testing.expect(missing.usage == .missing_value);
try std.testing.expectEqualStrings("--sock", missing.usage.missing_value);
}
test "parseArgs: a command-scoped option is rejected by every other command" {
// `--start` lets a cold `mux HOST` start and query the daemon in one SSH
// invocation.
const on = parse(&.{ "d", "endpoint", "--start" });
try std.testing.expect(on == .command);
try std.testing.expect(on.command._cmd == .endpoint);
try std.testing.expect(on.command.start);
// Polling omits `--start`; inheriting true would start a daemon during every
// read-only wall poll.
try std.testing.expect(!parse(&.{ "d", "endpoint" }).command.start);
// Check every command-table row so newly added commands cannot accidentally
// inherit command-specific options. Rows that intentionally ignore all
// arguments are excluded.
const scoped = .{ .{ "--start", DaemonCommand.endpoint }, .{ "-d", DaemonCommand.start } };
inline for (specs) |s| {
if (s.flags == .ignored) continue;
inline for (scoped) |w| {
if (s.cmd != w[1]) {
const r = parseArgs(&.{ "d", nameZ(s.name), w[0] });
// expect() alone prints "expected true", which does not
// say which verb let which word through.
if (r != .usage) std.debug.print(
"`mux d {s} {s}` was accepted; {s} belongs to one verb\n",
.{ s.name, w[0], w[0] },
);
try std.testing.expect(r == .usage);
try std.testing.expectEqual(@as(u8, 2), usageCode(r.usage));
}
}
}
}
test "parseArgs: start --resume-fd N --check is the old daemon's dry run" {
const r = parse(&.{ "d", "start", "--resume-fd", "7", "--check" });
try std.testing.expect(r == .command);
try std.testing.expect(r.command._cmd == .start);
try std.testing.expectEqual(@as(std.posix.fd_t, 7), r.command.resume_fd.?);
try std.testing.expect(r.command.check);
// The manifest descriptor must parse as an integer.
try std.testing.expect(parse(&.{ "d", "start", "--resume-fd", "x" }).usage == .bad_value);
const f = parse(&.{ "d", "start", "--resume-fd", "3", "--resume-fail-at", "session" });
try std.testing.expectEqualStrings("session", f.command.resume_fail_at.?);
// Ordinary startup must not enter resume or validation mode.
const plain = parse(&.{ "d", "start" });
try std.testing.expect(plain.command.resume_fd == null);
try std.testing.expect(!plain.command.check);
}
test "failAtFrom: the flag beats the environment, and neither is no abort" {
try std.testing.expectEqualStrings("session", failAtFrom("session", "daemon"));
// E2e failure injection uses the environment because upgrade builds fixed
// argv.
try std.testing.expectEqualStrings("daemon", failAtFrom(null, "daemon"));
try std.testing.expectEqualStrings("", failAtFrom(null, null));
}
test "rollbackKeepsEnv: the rollback does not inherit the abort that caused it" {
// The rollback target must not inherit the failure injection that triggered
// rollback.
try std.testing.expect(!rollbackKeepsEnv("MUX_RESUME_FAIL_AT=session"));
try std.testing.expect(rollbackKeepsEnv("MUX_SHELL_INTEGRATION=1"));
// The name is a prefix of nothing else, but a variable that merely
// starts with the same letters is not this one.
try std.testing.expect(rollbackKeepsEnv("MUX_RESUME_FAIL_AT_NOT=1"));
}
test "parseArgs: upgrade is a command, and same-version is a flag it takes" {
const r = parse(&.{ "d", "upgrade" });
try std.testing.expect(r == .command);
try std.testing.expect(r.command._cmd == .upgrade);
// Strictly newer remains the default unless the exception is explicit.
try std.testing.expect(!r.command.allow_same_version);
const s = parse(&.{ "d", "upgrade", "--sock", "/tmp/x.sock", "--allow-same-version" });
try std.testing.expect(s.command._cmd == .upgrade);
try std.testing.expectEqualStrings("/tmp/x.sock", s.command.sock.?);
try std.testing.expect(s.command.allow_same_version);
}
test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abort" {
const alloc = std.testing.allocator;
const carrier = try server_os.anonFd("mux-resume-check-test");
defer std.posix.close(carrier);
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(alloc);
try upgrade.writeManifest(buf.writer(alloc), alloc, .{
.writer_version = "0.0.1-99",
// Use a non-executable rollback target so an incorrect rollback from
// validation fails locally instead of replacing the test runner.
.writer_path = "/nonexistent/mux",
.sock_path = "/tmp/mux-resume-check-test.sock",
.listener_fd = -1,
.shellint_dir = null,
.agent_dir = null,
.shell = "/bin/sh",
.shell_integration = false,
.extra_env = &.{},
.quic = .{},
.counters = .{},
}, &.{});
var file = std.fs.File{ .handle = carrier };
try file.writeAll(buf.items);
const code = try resumeRun(alloc, .{
._cmd = .start,
.check = true,
.resume_fd = carrier,
.resume_fail_at = "daemon",
}, carrier);
try std.testing.expectEqual(@as(u8, 0), code);
}
test "announceKeyFrom: MUX_KEY_FILE wins, and the default it skipped is not created" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var ebuf: [280]u8 = undefined;
var dbuf: [280]u8 = undefined;
const env = try std.fmt.bufPrint(&ebuf, "{s}/env-key", .{tmp.path()});
const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()});
try xdg.writeNewKey(env);
const r = announceKeyFrom(env, dflt);
try std.testing.expect(r == .key);
// The loaded key must match the selected environment file exactly.
var on_disk: [32]u8 = undefined;
try std.testing.expectEqualSlices(u8, try std.fs.cwd().readFile(env, &on_disk), &r.key.bytes);
// Selecting an environment key must not create an unused default credential
// that a later daemon might select.
try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(dflt, .{}));
}
test "announceKeyFrom: the default is created when absent, and no path at all is no_path" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var dbuf: [280]u8 = undefined;
const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()});
// First use creates the default key automatically.
const made = announceKeyFrom(null, dflt);
try std.testing.expect(made == .key);
const st = try std.fs.cwd().statFile(dflt);
try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(st.mode & 0o777)));
// Subsequent calls load the same key instead of rotating it.
const again = announceKeyFrom(null, dflt);
try std.testing.expect(again == .key);
try std.testing.expectEqualSlices(u8, &made.key.bytes, &again.key.bytes);
try std.testing.expect(announceKeyFrom(null, null) == .no_path);
}
test "announceKeyFrom: a default that cannot be created reports the create, not the load" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var robuf: [280]u8 = undefined;
var dbuf: [280]u8 = undefined;
const ro = try std.fmt.bufPrint(&robuf, "{s}/ro", .{tmp.path()});
const dflt = try std.fmt.bufPrint(&dbuf, "{s}/mux/key", .{ro});
try std.fs.cwd().makePath(ro);
{
var d = try std.fs.cwd().openDir(ro, .{ .iterate = true });
defer d.close();
try d.chmod(0o500);
}
// Left at 0500 for cleanup, deliberately: 0500 still grants read and
// execute, so deleteTree can enter and list it, and removing the empty
// directory itself needs write on the tmp ROOT, which is untouched.
// Emptiness is not an assumption — it is the assertion below.
// Swallowing the create error leaves the load to say `no such key file`,
// which names the symptom and sends the reader looking for a file when the
// story is a directory they cannot write.
const r = announceKeyFrom(null, dflt);
try std.testing.expect(r == .create_failed);
try std.testing.expectEqual(error.AccessDenied, r.create_failed.err);
try std.testing.expectEqualStrings(dflt, r.create_failed.path);
}
test "askEndpointPort: a socket nobody serves answers 0, quickly" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [280]u8 = undefined;
const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
// Zero is the announce-none path, and reaching it FAST is the point: the
// connect refusal is immediate, so the bounded wait is never entered. One
// that polled to the deadline would answer the same 0 two seconds later.
const t0 = std.time.milliTimestamp();
try std.testing.expectEqual(@as(u16, 0), askEndpointPort(std.testing.allocator, sock));
try std.testing.expect(std.time.milliTimestamp() - t0 < 500);
}
test "endpointCmd: polling an absent daemon does not start one" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [280]u8 = undefined;
const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
// Capture output in a file so the test can verify that no announcement was
// written without touching the test runner's stdout.
var out_buf: [280]u8 = undefined;
const out_path = try std.fmt.bufPrint(&out_buf, "{s}/announce", .{tmp.path()});
const out = try std.fs.cwd().createFile(out_path, .{});
defer out.close();
try std.testing.expectEqual(
@as(u8, 1),
// False models background polling. The true startup path requires the
// production XDG log path and is covered by e2e tests.
try endpointCmd(std.testing.allocator, sock, out.handle, false),
);
// A read-only poll must not create a daemon or default session, especially
// immediately after an explicit stop.
try std.testing.expect(!sockpath.answers(sock));
try std.testing.expectEqual(@as(u64, 0), (try out.stat()).size);
}
test "oneShotQuery: a socket nobody serves is exit 1" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [280]u8 = undefined;
const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
// Unlike idempotent stop, dump and stats require a daemon response and
// therefore return one for an absent socket.
try std.testing.expectEqual(
@as(u8, 1),
try oneShotQuery(std.testing.allocator, .{ .sock = sock }, "dump", .debug_dump, "", .dump_reply),
);
try std.testing.expectEqual(
@as(u8, 1),
try oneShotQuery(std.testing.allocator, .{ .sock = sock }, "stats", .stats_req, "", .stats_reply),
);
}
test "stopCmd: a socket path with nothing on it is exit 0, not a failure" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [280]u8 = undefined;
const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
try std.testing.expectEqual(@as(u8, 0), try stopCmd(std.testing.allocator, .{ .sock = sock }));
}
test "peerPid: the kernel names the peer" {
// Both socketpair endpoints belong to this process, so kernel credentials
// must report the current pid.
var sp: [2]i32 = undefined;
try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp));
defer std.posix.close(sp[0]);
defer std.posix.close(sp[1]);
try std.testing.expectEqual(server_os.getpid(), peerPid(sp[0]).?);
}
test "waitPidGone: returns only once the OS has no such process" {
// Use a grandchild because a direct child can remain as a zombie that signal
// zero still finds. The shell exits after printing the sleeper pid, leaving
// the reparented process to terminate independently.
//
// The sleeper's stdio goes to /dev/null so it does not hold the pipe this
// test reads the pid through. Inheriting it made the read below block for
// the sleeper's whole lifetime, so the pid was already dying by the time
// the aliveness check ran and the wait proved nothing — on macOS the check
// lost that race outright and the test failed at its first line. A second
// of life, spent while `waitPidGone` polls, is what makes the wait mean
// something.
var child = std.process.Child.init(&.{ "sh", "-c", "sleep 1 >/dev/null 2>&1 </dev/null & echo $!" }, std.testing.allocator);
child.stdout_behavior = .Pipe;
try child.spawn();
var buf: [32]u8 = undefined;
const n = try child.stdout.?.readAll(&buf);
_ = try child.wait();
const pid = try std.fmt.parseInt(std.posix.pid_t, std.mem.trim(u8, buf[0..n], "\n "), 10);
try std.posix.kill(pid, 0); // alive when we start, or the wait proves nothing
try std.testing.expectEqual(@as(u8, 0), waitPidGone(std.testing.allocator, pid, "(test)", true));
try std.testing.expectError(error.ProcessNotFound, std.posix.kill(pid, 0));
}
test "shellIntegrationEnabled: an unset environment means off" {
// Default off because the zsh integration changes `~/.zshenv` handling and
// the Bash integration replaces the DEBUG trap.
try std.testing.expect(!shellIntegrationEnabled(null));
}
test "shellIntegrationEnabled: `1` and nothing else turns it on" {
try std.testing.expect(shellIntegrationEnabled("1"));
// Every other value remains off, including stale `=0` configurations from
// the previous opt-out behavior.
try std.testing.expect(!shellIntegrationEnabled("0"));
try std.testing.expect(!shellIntegrationEnabled(""));
try std.testing.expect(!shellIntegrationEnabled("true"));
try std.testing.expect(!shellIntegrationEnabled("yes"));
}
fn silentProgress() StartProgress {
// StartProgress that writes to /dev/null keeps test output clean while the
// pinned-output case below captures a pipe instead.
const f = std.fs.cwd().openFile("/dev/null", .{ .mode = .write_only }) catch unreachable;
return .{ .fd = f.handle, .prefix = "test", .tty = false };
}
test "start -d: an answering socket is already_running, nothing spawned" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [128]u8 = undefined;
const sock = try std.fmt.bufPrint(&buf, "{s}/live.sock", .{tmp.path()});
const addr = try std.net.Address.initUnix(sock);
var server = try addr.listen(.{});
defer server.deinit();
// Capture progress and verify that the already-running path is silent.
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
const progress: StartProgress = .{ .fd = pipe[1], .prefix = "test", .tty = false };
const r = try forkDaemon(
std.testing.allocator,
"/definitely/not/consulted",
&.{},
sock,
progress,
200,
null,
);
try std.testing.expectEqual(StartOutcome.already_running, r);
std.posix.close(pipe[1]);
var out: [64]u8 = undefined;
try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &out));
}
test "start -d: a socket this process may not reach is not `already running`" {
// chmod does not bite root; as root the connect succeeds and the
// premise of the test is gone.
if (std.posix.geteuid() == 0) return error.SkipZigTest;
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [128]u8 = undefined;
const sock = try std.fmt.bufPrint(&buf, "{s}/mode0.sock", .{tmp.path()});
const addr = try std.net.Address.initUnix(sock);
var server = try addr.listen(.{});
defer server.deinit();
try std.posix.fchmodat(std.posix.AT.FDCWD, sock, 0, 0);
// Reaching `SpawnFailed` proves the inaccessible socket was not mistaken for
// an already-running daemon.
try std.testing.expectError(error.SpawnFailed, forkDaemon(
std.testing.allocator,
"/no/such/mux",
&.{},
sock,
silentProgress(),
200,
null,
));
}
test "start -d: a missing binary is SpawnFailed before any fork" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var buf: [128]u8 = undefined;
const sock = try std.fmt.bufPrint(&buf, "{s}/none.sock", .{tmp.path()});
try std.testing.expectError(error.SpawnFailed, forkDaemon(
std.testing.allocator,
"/no/such/mux",
&.{},
sock,
silentProgress(),
200,
null,
));
}
test "start -d: a child that dies young is reported, not panicked on" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var pbuf: [128]u8 = undefined;
var sbuf: [128]u8 = undefined;
var lbuf: [128]u8 = undefined;
const stub = try std.fmt.bufPrint(&pbuf, "{s}/dies.sh", .{tmp.path()});
const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()});
const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
// Model a child that exits before binding. The poll loop must reap it once
// and continue to the deadline without a second waitpid call.
try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" });
const f = try tmp.dir.openFile("dies.sh", .{});
try f.chmod(0o755);
f.close();
try std.testing.expectError(error.NeverAnswered, forkDaemon(
std.testing.allocator,
stub,
&.{},
sock,
silentProgress(),
300,
log,
));
// The log must remain available for the startup-failure diagnostic.
const log_st = try std.fs.cwd().statFile(log);
try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
}
test "start -d: a binary that never binds is NeverAnswered, pid left alive" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var pbuf: [128]u8 = undefined;
var sbuf: [128]u8 = undefined;
var lbuf: [128]u8 = undefined;
const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
const sock = try std.fmt.bufPrint(&sbuf, "{s}/never.sock", .{tmp.path()});
const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
// Exec the sleeper so the tracked pid belongs to the persistent process,
// not an intermediate shell.
try tmp.dir.writeFile(.{ .sub_path = "stub.sh", .data = "#!/bin/sh\nexec sleep 30\n" });
const f = try tmp.dir.openFile("stub.sh", .{});
try f.chmod(0o755);
f.close();
// Override the log path because tests cannot change XDG_STATE_HOME. The
// nested path also verifies parent-directory creation.
const t0 = std.time.milliTimestamp();
try std.testing.expectError(error.NeverAnswered, forkDaemon(
std.testing.allocator,
stub,
&.{},
sock,
silentProgress(),
300,
log,
));
// Startup creates the log before fork, so it exists even when the child
// writes nothing.
const log_st = try std.fs.cwd().statFile(log);
try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
// It waited the deadline out rather than bailing early...
try std.testing.expect(std.time.milliTimestamp() - t0 >= 300);
// Deadline expiry must not kill the spawned process. Use the exact tracked
// pid for cleanup to avoid affecting unrelated processes.
try std.testing.expect(last_spawned_pid != 0);
// waitpid with NOHANG returning pid 0 means "child exists, still
// running", which is the assertion; a reaped or dead child returns its
// own pid instead.
try std.testing.expectEqual(
@as(std.posix.pid_t, 0),
std.posix.waitpid(last_spawned_pid, std.posix.W.NOHANG).pid,
);
std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
_ = std.posix.waitpid(last_spawned_pid, 0);
}
test "start -d: the child executes the supplied path without searching PATH" {
const testtmp = @import("testtmp");
var tmp = try testtmp.TmpDir.make();
defer tmp.cleanup();
var pbuf: [160]u8 = undefined;
var sbuf: [160]u8 = undefined;
var lbuf: [160]u8 = undefined;
var rbuf: [160]u8 = undefined;
const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
const sock = try std.fmt.bufPrint(&sbuf, "{s}/self.sock", .{tmp.path()});
const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/mux.log", .{tmp.path()});
const seen = try std.fmt.bufPrint(&rbuf, "{s}/child.exe", .{tmp.path()});
// Record `$0` and `$*` to verify both the exact executable path and the
// explicit `d start` mode words used for process listings.
var script: [512]u8 = undefined;
try tmp.dir.writeFile(.{
.sub_path = "stub.sh",
.data = try std.fmt.bufPrint(&script,
\\#!/bin/sh
\\printf '%s|%s' "$0" "$*" > "{s}"
\\exec sleep 30
\\
, .{seen}),
});
const f = try tmp.dir.openFile("stub.sh", .{});
try f.chmod(0o755);
f.close();
try std.testing.expectError(error.NeverAnswered, forkDaemon(
std.testing.allocator,
stub,
&.{},
sock,
silentProgress(),
400,
log,
));
defer {
std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
_ = std.posix.waitpid(last_spawned_pid, 0);
}
var got_buf: [std.fs.max_path_bytes]u8 = undefined;
// Convert a missing marker into a readable mismatch so the assertion still
// identifies an unexpected executable.
const got = std.fs.cwd().readFile(seen, &got_buf) catch "<the child ran something else>";
var want_buf: [200]u8 = undefined;
const want = try std.fmt.bufPrint(&want_buf, "{s}|d start", .{stub});
try std.testing.expectEqualStrings(want, std.mem.trimRight(u8, got, "\n"));
}
// Ensure every public declaration is semantically analyzed during tests;
// `std.meta.declarations` does not include private declarations.
test {
std.testing.refAllDeclsRecursive(@This());
}