d587aa2c
chore: delete socket activation and contrib units — muxd start supersedes them
a73x 2026-08-09 15:20
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -43,15 +43,16 @@ Inside a session: | |||
| 43 | 43 | ||
| 44 | ## Quick start, remote over ssh | 44 | ## Quick start, remote over ssh |
| 45 | 45 | ||
| 46 | Works anywhere ssh works. On the remote host: put `muxd` on PATH and start | 46 | Works anywhere ssh works. On the remote host: put `muxd` on PATH, then: |
| 47 | the daemon once (`loginctl enable-linger $USER` keeps it alive after you | ||
| 48 | log out; systemd user units are in `contrib/`). | ||
| 49 | 47 | ||
| 50 | ```sh | 48 | ```sh |
| 51 | ssh HOST 'setsid nohup muxd run >/dev/null 2>&1 &' | 49 | ssh HOST 'muxd start' |
| 52 | mux HOST # attach; Ctrl-\ detaches, running it again reattaches | 50 | mux HOST # attach; Ctrl-\ detaches, running it again reattaches |
| 53 | ``` | 51 | ``` |
| 54 | 52 | ||
| 53 | `muxd start` spawns the daemon detached and waits until it answers, so it | ||
| 54 | is safe to run every time — if one is already up it says so and exits 0. | ||
| 55 | |||
| 55 | `mux HOST` is sugar for `mux --via "ssh HOST muxd proxy"` — ssh config | 56 | `mux HOST` is sugar for `mux --via "ssh HOST muxd proxy"` — ssh config |
| 56 | (aliases, ports, ProxyJump) all keeps working, since `mux` never parses | 57 | (aliases, ports, ProxyJump) all keeps working, since `mux` never parses |
| 57 | HOST. It **is** interpolated into a shell command, so HOST must be trusted: | 58 | HOST. It **is** interpolated into a shell command, so HOST must be trusted: |
| @@ -68,13 +69,20 @@ No ssh in the path; needs inbound UDP to the host. Authentication is a | |||
| 68 | shared 32-byte key (TLS 1.3 external PSK — no certificates), so the | 69 | shared 32-byte key (TLS 1.3 external PSK — no certificates), so the |
| 69 | handshake is one round trip. | 70 | handshake is one round trip. |
| 70 | 71 | ||
| 72 | Put `muxd` on the host (`scp` a static binary), then: | ||
| 73 | |||
| 71 | ```sh | 74 | ```sh |
| 72 | head -c 32 /dev/urandom > ~/.mux.key && chmod 600 ~/.mux.key # 32 raw bytes or 64 hex chars | 75 | muxd keygen # once |
| 73 | scp ~/.mux.key muxd HOST: # the key and a static binary | 76 | ssh HOST 'mkdir -p -m 700 ~/.config/mux && cat > ~/.config/mux/key \ |
| 74 | ssh HOST 'chmod 600 ~/.mux.key; setsid nohup muxd run --quic 0.0.0.0:4433 --key ~/.mux.key >/dev/null 2>&1 &' | 77 | && chmod 600 ~/.config/mux/key' < ~/.config/mux/key # once per host |
| 75 | mux quic://HOST:4433 --key ~/.mux.key # or set MUX_KEY_FILE | 78 | ssh HOST 'muxd start --quic 0.0.0.0' # once per host boot |
| 79 | mux quic://HOST # every attach | ||
| 76 | ``` | 80 | ``` |
| 77 | 81 | ||
| 82 | The port defaults to 4433 on both ends, and both binaries look for the key | ||
| 83 | at `~/.config/mux/key` — `--key FILE` or `MUX_KEY_FILE` override it, in | ||
| 84 | that order. | ||
| 85 | |||
| 78 | **Trust model, in one sentence:** anyone holding that key file can attach | 86 | **Trust model, in one sentence:** anyone holding that key file can attach |
| 79 | to that session, so it is exactly as sensitive as an ssh private key — | 87 | to that session, so it is exactly as sensitive as an ssh private key — |
| 80 | `muxd` and `mux` both refuse one that is readable by group or other, and | 88 | `muxd` and `mux` both refuse one that is readable by group or other, and |
| @@ -90,7 +98,9 @@ make bench # typing-workload byte-ratio measurement | |||
| 90 | 98 | ||
| 91 | Multiple clients may attach to one session; the grid follows the most | 99 | Multiple clients may attach to one session; the grid follows the most |
| 92 | recently active client — typing, attaching, or resizing claims it (latest | 100 | recently active client — typing, attaching, or resizing claims it (latest |
| 93 | wins). With linger enabled a session survives logout, though not a reboot. | 101 | wins). A session survives logout (this assumes systemd-logind's default |
| 102 | `KillUserProcesses=no`; a box configured to kill user processes at logout | ||
| 103 | kills the daemon with them), though not a reboot. | ||
| 94 | `muxd run` refuses a socket another daemon already owns; there is no | 104 | `muxd run` refuses a socket another daemon already owns; there is no |
| 95 | socket-stealing. | 105 | socket-stealing. |
| 96 | 106 | ||
contrib/muxd.service
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +0,0 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=mux daemon (prototype) | ||
| 3 | Requires=muxd.socket | ||
| 4 | |||
| 5 | [Service] | ||
| 6 | # Adjust the path to your checkout; the prototype is not installed system-wide. | ||
| 7 | ExecStart=%h/code/rad/mux/zig-out/bin/muxd run | ||
| 8 | Restart=no | ||
contrib/muxd.socket
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +0,0 @@ | |||
| 1 | [Unit] | ||
| 2 | Description=mux daemon socket | ||
| 3 | |||
| 4 | [Socket] | ||
| 5 | ListenStream=%t/muxd.sock | ||
| 6 | |||
| 7 | [Install] | ||
| 8 | WantedBy=sockets.target | ||
docs/superpowers/plans/2026-08-09-m10-quic-ergonomics.md
| Old | New | ||
|---|---|---|---|
| @@ -1401,7 +1401,7 @@ git commit -m "fix: a --via command that died before connecting no longer report | |||
| 1401 | - Modify: `src/server.zig` (~350, ~405–460, ~507–514, ~532) | 1401 | - Modify: `src/server.zig` (~350, ~405–460, ~507–514, ~532) |
| 1402 | - Modify: `README.md` (ssh quick start ~44–63, QUIC quick start ~65–81, multi-client paragraph ~90–95) | 1402 | - Modify: `README.md` (ssh quick start ~44–63, QUIC quick start ~65–81, multi-client paragraph ~90–95) |
| 1403 | 1403 | ||
| 1404 | - [ ] **Step 1: server.zig — remove the activation path** | 1404 | - [x] **Step 1: server.zig — remove the activation path** |
| 1405 | 1405 | ||
| 1406 | - Delete `listenFdFromSystemd` (lines ~507–514). | 1406 | - Delete `listenFdFromSystemd` (lines ~507–514). |
| 1407 | - In `Server.init` (~405): delete the `systemd_fd` read and its comment, delete the whole `if (systemd_fd) |fd| { ... }` return block (~428–441), and change the claim line to unconditional: | 1407 | - In `Server.init` (~405): delete the `systemd_fd` read and its comment, delete the whole `if (systemd_fd) |fd| { ... }` return block (~428–441), and change the claim line to unconditional: |
| @@ -1415,13 +1415,13 @@ git commit -m "fix: a --via command that died before connecting no longer report | |||
| 1415 | - Delete the `owns_sock_file` field (~350) and its `.owns_sock_file = true` initializer (~455). | 1415 | - Delete the `owns_sock_file` field (~350) and its `.owns_sock_file = true` initializer (~455). |
| 1416 | - In teardown (~532): delete the line `if (!self.owns_sock_file) break :ours false;`. The dev/ino identity check STAYS — "only unlink the socket you created" guards against a different hazard (another daemon's socket at the same path) than activation was. | 1416 | - In teardown (~532): delete the line `if (!self.owns_sock_file) break :ours false;`. The dev/ino identity check STAYS — "only unlink the socket you created" guards against a different hazard (another daemon's socket at the same path) than activation was. |
| 1417 | 1417 | ||
| 1418 | - [ ] **Step 2: Delete contrib** | 1418 | - [x] **Step 2: Delete contrib** |
| 1419 | 1419 | ||
| 1420 | ```bash | 1420 | ```bash |
| 1421 | git rm contrib/muxd.service contrib/muxd.socket | 1421 | git rm contrib/muxd.service contrib/muxd.socket |
| 1422 | ``` | 1422 | ``` |
| 1423 | 1423 | ||
| 1424 | - [ ] **Step 3: README** | 1424 | - [x] **Step 3: README** |
| 1425 | 1425 | ||
| 1426 | Replace the ssh quick-start's daemon line (~50–53): | 1426 | Replace the ssh quick-start's daemon line (~50–53): |
| 1427 | 1427 | ||
| @@ -1454,12 +1454,12 @@ A session survives logout (this assumes systemd-logind's default | |||
| 1454 | kills the daemon with them), though not a reboot. | 1454 | kills the daemon with them), though not a reboot. |
| 1455 | ``` | 1455 | ``` |
| 1456 | 1456 | ||
| 1457 | - [ ] **Step 4: Verify nothing referenced the deleted code** | 1457 | - [x] **Step 4: Verify nothing referenced the deleted code** |
| 1458 | 1458 | ||
| 1459 | Run: `grep -rn "LISTEN_FDS\|listenFdFromSystemd\|owns_sock_file\|contrib/" src/ test/ README.md Makefile build.zig` | 1459 | Run: `grep -rn "LISTEN_FDS\|listenFdFromSystemd\|owns_sock_file\|contrib/" src/ test/ README.md Makefile build.zig` |
| 1460 | Expected: no matches. Then `make test && make build && make e2e` — all pass (the server module's existing socket tests cover the always-claim path). | 1460 | Expected: no matches. Then `make test && make build && make e2e` — all pass (the server module's existing socket tests cover the always-claim path). |
| 1461 | 1461 | ||
| 1462 | - [ ] **Step 5: Commit** | 1462 | - [x] **Step 5: Commit** |
| 1463 | 1463 | ||
| 1464 | ```bash | 1464 | ```bash |
| 1465 | git add -A | 1465 | git add -A |
src/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -347,7 +347,6 @@ pub const Server = struct { | |||
| 347 | pty: Pty, | 347 | pty: Pty, |
| 348 | listener: std.net.Server, | 348 | listener: std.net.Server, |
| 349 | sock_path: []const u8, | 349 | sock_path: []const u8, |
| 350 | owns_sock_file: bool, | ||
| 351 | /// The socket file's identity at the moment it was bound, so teardown | 350 | /// The socket file's identity at the moment it was bound, so teardown |
| 352 | /// can tell our socket from one that replaced it. | 351 | /// can tell our socket from one that replaced it. |
| 353 | /// | 352 | /// |
| @@ -358,8 +357,8 @@ pub const Server = struct { | |||
| 358 | /// and was unconditionally false, which meant the daemon never unlinked | 357 | /// and was unconditionally false, which meant the daemon never unlinked |
| 359 | /// its socket on a clean exit at all — masked ever since by the stale | 358 | /// its socket on a clean exit at all — masked ever since by the stale |
| 360 | /// socket recovery in `claimSockPath` cleaning up on the next start. | 359 | /// socket recovery in `claimSockPath` cleaning up on the next start. |
| 361 | sock_dev: u64 = 0, | 360 | sock_dev: u64, |
| 362 | sock_ino: u64 = 0, | 361 | sock_ino: u64, |
| 363 | /// The attached interactive clients. All of them see every update. | 362 | /// The attached interactive clients. All of them see every update. |
| 364 | clients: [max_clients]?ClientSlot = @splat(null), | 363 | clients: [max_clients]?ClientSlot = @splat(null), |
| 365 | /// How much unsent output one client may accumulate before the daemon | 364 | /// How much unsent output one client may accumulate before the daemon |
| @@ -403,15 +402,9 @@ pub const Server = struct { | |||
| 403 | }; | 402 | }; |
| 404 | 403 | ||
| 405 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { | 404 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { |
| 406 | // systemd socket activation: LISTEN_FDS=1 hands us the listener as | ||
| 407 | // fd 3, and the path is systemd's to manage — we neither probe nor | ||
| 408 | // unlink it. Read once, up front, so the claim below can be the | ||
| 409 | // first thing that happens on the self-bind path. | ||
| 410 | const systemd_fd = listenFdFromSystemd(); | ||
| 411 | |||
| 412 | // Before the shell is spawned, so refusing costs nobody a fork and | 405 | // Before the shell is spawned, so refusing costs nobody a fork and |
| 413 | // leaves no process to reap. | 406 | // leaves no process to reap. |
| 414 | if (systemd_fd == null) try claimSockPath(opts.sock_path); | 407 | try claimSockPath(opts.sock_path); |
| 415 | 408 | ||
| 416 | const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows }); | 409 | const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows }); |
| 417 | errdefer eng.deinit(); | 410 | errdefer eng.deinit(); |
| @@ -425,21 +418,6 @@ pub const Server = struct { | |||
| 425 | var epoch: u64 = 0; | 418 | var epoch: u64 = 0; |
| 426 | while (epoch == 0) epoch = std.crypto.random.int(u64); | 419 | while (epoch == 0) epoch = std.crypto.random.int(u64); |
| 427 | 420 | ||
| 428 | if (systemd_fd) |fd| { | ||
| 429 | return .{ | ||
| 430 | .alloc = alloc, | ||
| 431 | .eng = eng, | ||
| 432 | .pty = pty, | ||
| 433 | .listener = .{ | ||
| 434 | .listen_address = undefined, | ||
| 435 | .stream = .{ .handle = fd }, | ||
| 436 | }, | ||
| 437 | .sock_path = opts.sock_path, | ||
| 438 | .owns_sock_file = false, | ||
| 439 | .epoch = epoch, | ||
| 440 | }; | ||
| 441 | } | ||
| 442 | |||
| 443 | const addr = try std.net.Address.initUnix(opts.sock_path); | 421 | const addr = try std.net.Address.initUnix(opts.sock_path); |
| 444 | const listener = try addr.listen(.{}); | 422 | const listener = try addr.listen(.{}); |
| 445 | // Stat the path we just created, not the descriptor: this is the | 423 | // Stat the path we just created, not the descriptor: this is the |
| @@ -452,7 +430,6 @@ pub const Server = struct { | |||
| 452 | .pty = pty, | 430 | .pty = pty, |
| 453 | .listener = listener, | 431 | .listener = listener, |
| 454 | .sock_path = opts.sock_path, | 432 | .sock_path = opts.sock_path, |
| 455 | .owns_sock_file = true, | ||
| 456 | .sock_dev = @intCast(st.dev), | 433 | .sock_dev = @intCast(st.dev), |
| 457 | .sock_ino = @intCast(st.ino), | 434 | .sock_ino = @intCast(st.ino), |
| 458 | .epoch = epoch, | 435 | .epoch = epoch, |
| @@ -504,15 +481,6 @@ pub const Server = struct { | |||
| 504 | }; | 481 | }; |
| 505 | } | 482 | } |
| 506 | 483 | ||
| 507 | fn listenFdFromSystemd() ?std.posix.fd_t { | ||
| 508 | const pid_s = std.posix.getenv("LISTEN_PID") orelse return null; | ||
| 509 | const nfds_s = std.posix.getenv("LISTEN_FDS") orelse return null; | ||
| 510 | const pid = std.fmt.parseInt(std.posix.pid_t, pid_s, 10) catch return null; | ||
| 511 | const nfds = std.fmt.parseInt(u32, nfds_s, 10) catch return null; | ||
| 512 | if (pid != std.os.linux.getpid() or nfds < 1) return null; | ||
| 513 | return 3; // SD_LISTEN_FDS_START | ||
| 514 | } | ||
| 515 | |||
| 516 | pub fn deinit(self: *Server) void { | 484 | pub fn deinit(self: *Server) void { |
| 517 | for (&self.clients) |*slot| { | 485 | for (&self.clients) |*slot| { |
| 518 | if (slot.*) |*c| { | 486 | if (slot.*) |*c| { |
| @@ -529,7 +497,6 @@ pub const Server = struct { | |||
| 529 | // deleting that one would hand its clients the same field incident | 497 | // deleting that one would hand its clients the same field incident |
| 530 | // the socket-steal fix exists to prevent. | 498 | // the socket-steal fix exists to prevent. |
| 531 | const ours: bool = ours: { | 499 | const ours: bool = ours: { |
| 532 | if (!self.owns_sock_file) break :ours false; | ||
| 533 | const pst = std.posix.fstatat(std.posix.AT.FDCWD, self.sock_path, 0) catch break :ours false; | 500 | const pst = std.posix.fstatat(std.posix.AT.FDCWD, self.sock_path, 0) catch break :ours false; |
| 534 | break :ours pst.dev == self.sock_dev and pst.ino == self.sock_ino; | 501 | break :ours pst.dev == self.sock_dev and pst.ino == self.sock_ino; |
| 535 | }; | 502 | }; |