a73x

d6a6f944

fix: endpoint refusals all speak; announceKey gains its testable seam

a73x   2026-08-11 13:30

Commit message
fix: endpoint refusals all speak; announceKey gains its testable seam

Review follow-ups to muxd endpoint.

The port==0 path announced `endpoint none` in total silence — the likeliest
production case, since the daemon's reason (no key it could load, a bind
that failed, a binary too old to know the verb) goes to ITS log, on a box
the ssh user is not sitting at. It now says so and names the log, with
stopCmd's conditional clause and its absent-HOME discipline.

announceKey splits into a decision (`announceKeyFrom`, environment handed
in, nothing printed) and the words, following pickKey and the daemon's
endpointPortFrom — the other half of the same decision. That seam is what
lets the create-vs-load distinction be a test rather than a claim: an
unwritable config directory must surface AccessDenied from the create, not
KeyFileMissing from the load, which is its symptom.

Also: the key refusals are word for word run's again, path in the same
position; the stdout-write failure names its error instead of guessing
EPIPE (a closed fd reports NotOpenForWriting); the announce write gets
proxy's SIGPIPE ignore, hoisted into `proxy.ignoreSigpipe` and called after
the auto-start, never before — SIG_IGN survives exec and the spawned daemon
must not inherit a disposition it never chose.

The parse test's comment claimed endpoint refuses a second flag; it does
not. The value-taking flags share one loop, so `endpoint --cols 100` parses
and is ignored. The comment now says that, and a case pins it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/main.zig
Old New
@@ -540,14 +540,28 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
540 /// The announce is mandatory in both directions. The client blocks on one 540 /// The announce is mandatory in both directions. The client blocks on one
541 /// newline-terminated line, and the daemon side of the frame protocol 541 /// newline-terminated line, and the daemon side of the frame protocol
542 /// sends nothing unprompted — so silence here is indistinguishable from a 542 /// sends nothing unprompted — so silence here is indistinguishable from a
543 /// slow ssh and would hang the attach rather than degrade it. Every path 543 /// slow ssh and would hang the attach rather than degrade it.
544 /// below therefore ends in a line, and the negative one is a line too. 544 ///
545 /// The two kinds of failure therefore end differently. A SOFT one — no
546 /// usable key, no listener — announces `endpoint none` and pumps anyway:
547 /// the ssh session is real and carries the whole session. A HARD one — no
548 /// daemon to pump to, a stdout that will not take the announce — exits,
549 /// and the client reads EOF on the pipe, which `handoff.readLine` already
550 /// tells apart from a line (`UnterminatedLine`). Announcing none and THEN
551 /// exiting is the one dishonest option available: it would tell the client
552 /// it has a working ssh session at the moment that session goes away.
545 /// 553 ///
546 /// stdout carries the announce and then frames, nothing else: every 554 /// stdout carries the announce and then frames, nothing else: every
547 /// human-facing word here goes to stderr, which ssh already carries to the 555 /// human-facing word here goes to stderr, which ssh already carries to the
548 /// user's terminal. That includes `ensureForAttach`'s progress, whose 556 /// user's terminal. That includes `ensureForAttach`'s progress, whose
549 /// Progress is pinned to STDERR_FILENO in spawn.zig — a stray stdout byte 557 /// Progress is pinned to STDERR_FILENO in spawn.zig — a stray stdout byte
550 /// ahead of the announce would land in the middle of the client's parse. 558 /// ahead of the announce would land in the middle of the client's parse.
559 ///
560 /// The pump that follows keeps speaking in its own name: a socket that
561 /// disappears between the ask and the attach is reported by proxy.zig as
562 /// `muxd proxy: cannot connect to …`. That is deliberate — reusing
563 /// `proxy.run` is the whole design, and the line names the code that
564 /// failed rather than the verb that was typed.
551 fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { 565 fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
552 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 566 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
553 const exe = std.fs.selfExePath(&exe_buf) catch { 567 const exe = std.fs.selfExePath(&exe_buf) catch {
@@ -556,6 +570,14 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
556 }; 570 };
557 if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd endpoint")) return 1; 571 if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd endpoint")) return 1;
558 572
573 // The announce goes out on the same stdout the pump is about to use,
574 // so it wants the same EPIPE-not-SIGPIPE treatment — and it wants it
575 // from proxy.zig's installer rather than from a std default this file
576 // would be leaning on. AFTER the auto-start above, never before: the
577 // ignore is SIG_IGN, which survives exec, so installing it first would
578 // hand the spawned daemon an inherited disposition it never chose.
579 proxy.ignoreSigpipe();
580
559 // Key first, then the ask, and the order is load-bearing: the daemon's 581 // Key first, then the ask, and the order is load-bearing: the daemon's
560 // lazy bind takes the default key path only if the file already exists 582 // lazy bind takes the default key path only if the file already exists
561 // and never creates one (server.zig endpointPortFrom). Creating it here 583 // and never creates one (server.zig endpointPortFrom). Creating it here
@@ -573,7 +595,10 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
573 // rather than doing it quietly, so an omission here would fail on 595 // rather than doing it quietly, so an omission here would fail on
574 // this line instead of arriving on another machine as a parse error 596 // this line instead of arriving on another machine as a parse error
575 // about a message we wrote. 597 // about a message we wrote.
576 if (port == 0) break :blk handoff.announce_none; 598 if (port == 0) {
599 reportNoListener(alloc, sock_path);
600 break :blk handoff.announce_none;
601 }
577 // Unreachable in fact — port 0 is gone by here, and line_buf is 602 // Unreachable in fact — port 0 is gone by here, and line_buf is
578 // sized by the same constant that bounds the grammar — but written 603 // sized by the same constant that bounds the grammar — but written
579 // as a fallback rather than `unreachable` because of what the two 604 // as a fallback rather than `unreachable` because of what the two
@@ -584,76 +609,157 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
584 break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch 609 break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch
585 handoff.announce_none; 610 handoff.announce_none;
586 }; 611 };
587 proto.writeAllFd(std.posix.STDOUT_FILENO, line) catch { 612 proto.writeAllFd(std.posix.STDOUT_FILENO, line) catch |err| {
588 // stdout is the pipe the pump is about to need, so there is no 613 // stdout is the pipe the pump is about to need, so there is no
589 // session left to fall back to — only a line about why. 614 // session left to fall back to — only a line about why. The error
590 std.debug.print("muxd endpoint: stdout closed before the announce\n", .{}); 615 // is named rather than guessed at: EPIPE (the ssh client gave up
616 // first) is the likely one, but a full disk under a redirect and a
617 // closed fd reach here too, and they want different reactions.
618 std.debug.print(
619 "muxd endpoint: cannot write the announce to stdout: {s}\n",
620 .{@errorName(err)},
621 );
591 return 1; 622 return 1;
592 }; 623 };
593 624
594 return proxy.run(sock_path); 625 return proxy.run(sock_path);
595 } 626 }
596 627
597 /// The key `muxd endpoint` announces, or null with one stderr line saying 628 /// The daemon is up and answering but has no QUIC listener to offer: no key
598 /// why not. 629 /// it could load, a bind that failed, or a binary too old to know the verb.
630 ///
631 /// This is the likeliest way the announce goes negative in production, and
632 /// it must not be silent. The daemon wrote the actual reason to ITS log —
633 /// on a box the person reading this line is not sitting at — so the line
634 /// says what happened and where the rest of the story is.
599 /// 635 ///
600 /// Resolution matches the daemon's lazy bind exactly — MUX_KEY_FILE, then 636 /// The log clause appears only when the path resolves, and hedges in its
601 /// the default path — because the announce hands a client the key it will 637 /// wording: a foreground `muxd run` logs to its own stderr, so naming the
638 /// xdg path unconditionally would guess. Both halves are `stopCmd`'s, for
639 /// the same reasons.
640 fn reportNoListener(alloc: std.mem.Allocator, sock_path: []const u8) void {
641 const log: ?[]const u8 = xdg.logPath(alloc) catch null;
642 defer if (log) |l| alloc.free(l);
643 if (log) |l| {
644 std.debug.print(
645 "muxd endpoint: the daemon on {s} produced no QUIC listener; staying on ssh (if it was started detached, its log is {s})\n",
646 .{ sock_path, l },
647 );
648 } else {
649 std.debug.print(
650 "muxd endpoint: the daemon on {s} produced no QUIC listener; staying on ssh\n",
651 .{sock_path},
652 );
653 }
654 }
655
656 /// The key `muxd endpoint` announces, or null with exactly one stderr line
657 /// saying why not. The reading of the environment and the deciding live in
658 /// `announceKeyFrom` below; this half owns the words.
659 fn announceKey(alloc: std.mem.Allocator) ?quic.Key {
660 // Resolved whether or not it is the one chosen, so the no-HOME case can
661 // be told apart from the have-a-path cases below.
662 const dflt: ?[]const u8 = xdg.keyPath(alloc) catch null;
663 defer if (dflt) |p| alloc.free(p);
664
665 switch (announceKeyFrom(envKey(), dflt)) {
666 .key => |k| return k,
667 .no_path => std.debug.print(
668 "muxd endpoint: no HOME to resolve a key path; staying on ssh\n",
669 .{},
670 ),
671 .create_failed => |f| std.debug.print(
672 "muxd endpoint: cannot create {s}: {s}; staying on ssh\n",
673 .{ f.path, @errorName(f.err) },
674 ),
675 .load_failed => |f| reportKeyRefusal(f.path, f.err),
676 }
677 return null;
678 }
679
680 /// What the key resolution decided and why, separated from the printing of
681 /// it. `pickKey` above exists for the same reason and the daemon's
682 /// `endpointPortFrom` is this decision's other half: the two must agree on
683 /// which file "the key" names, and an order that quietly inverted would
684 /// otherwise show up only as a client authenticating to nothing.
685 const KeyResult = union(enum) {
686 key: quic.Key,
687 /// No MUX_KEY_FILE and no HOME to build a default under: there is not
688 /// even a path to try.
689 no_path,
690 /// The default key was absent and could not be created. Kept apart
691 /// from `load_failed` because it is the cause and the load's
692 /// `KeyFileMissing` would only be its symptom.
693 create_failed: struct { path: []const u8, err: anyerror },
694 load_failed: struct { path: []const u8, err: anyerror },
695 };
696
697 /// MUX_KEY_FILE, then the default path — the same order the daemon's lazy
698 /// bind uses, because the announce hands a client the key it will
602 /// authenticate WITH against a listener holding whatever the daemon 699 /// authenticate WITH against a listener holding whatever the daemon
603 /// loaded. Two spellings of "the key" would attach to nothing. 700 /// loaded. Two spellings of "the key" would attach to nothing.
604 /// 701 ///
605 /// Only the default is created when absent: that is the mosh-server move. 702 /// Only the default is created when absent: that is the mosh-server move,
606 /// A MUX_KEY_FILE that is set but missing names a file the user manages, 703 /// and it is what lets a first attach to a fresh box produce coordinates
607 /// and writing one there would be a credential appearing where nobody 704 /// at all. A MUX_KEY_FILE that is set but missing names a file the user
608 /// asked for it. 705 /// manages, and writing one there would be a credential appearing where
609 fn announceKey(alloc: std.mem.Allocator) ?quic.Key { 706 /// nobody asked for it — so that path is loaded, never created.
610 if (envKey()) |p| return loadAnnounceKey(p); 707 ///
611 const dflt = xdg.keyPath(alloc) catch { 708 /// Environment handed in and nothing printed, so the order and the
612 std.debug.print("muxd endpoint: no HOME to resolve a key path; staying on ssh\n", .{}); 709 /// create-vs-load distinction are testable: the `*From` discipline xdg.zig
613 return null; 710 /// set and server.zig's `endpointPortFrom` follows.
614 }; 711 fn announceKeyFrom(env: ?[]const u8, dflt: ?[]const u8) KeyResult {
615 defer alloc.free(dflt); 712 if (env) |p| return if (quic.Key.load(p)) |k|
713 .{ .key = k }
714 else |err|
715 .{ .load_failed = .{ .path = p, .err = err } };
616 716
717 const path = dflt orelse return .no_path;
617 // KeyExists is the ordinary case and no news: the key is already there 718 // KeyExists is the ordinary case and no news: the key is already there
618 // and the load below is what wanted it. Any OTHER create failure is 719 // and the load below is what wanted it. Any OTHER create failure is
619 // kept, because if the load then fails too it is the create that holds 720 // kept, because if the load then fails too it is the create that holds
620 // the real reason — an unwritable config directory, a full disk — and 721 // the reason — an unwritable config directory, a full disk — while the
621 // `no such key file` would name the symptom while burying the cause on 722 // load can only report the file's absence, which is its symptom.
622 // a box the person reading this line is not sitting at. 723 const create_failed: ?anyerror = if (xdg.writeNewKey(path)) |_|
623 const create_failed: ?anyerror = if (xdg.writeNewKey(dflt)) |_|
624 null 724 null
625 else |err| 725 else |err|
626 if (err == error.KeyExists) null else err; 726 if (err == error.KeyExists) null else err;
627 727
628 return quic.Key.load(dflt) catch |load_err| { 728 return if (quic.Key.load(path)) |k|
629 if (create_failed) |err| { 729 .{ .key = k }
630 std.debug.print( 730 else |load_err| if (create_failed) |err|
631 "muxd endpoint: cannot create {s}: {s}; staying on ssh\n", 731 .{ .create_failed = .{ .path = path, .err = err } }
632 .{ dflt, @errorName(err) }, 732 else
633 ); 733 .{ .load_failed = .{ .path = path, .err = load_err } };
634 } else reportKeyRefusal(dflt, load_err);
635 return null;
636 };
637 }
638
639 fn loadAnnounceKey(path: []const u8) ?quic.Key {
640 return quic.Key.load(path) catch |err| {
641 reportKeyRefusal(path, err);
642 return null;
643 };
644 } 734 }
645 735
646 /// One line, naming the reason and not just the verdict. It rides ssh's 736 /// One line, naming the reason and not just the verdict. It rides ssh's
647 /// stderr to someone who is not on that box: "no usable key" alone would 737 /// stderr to someone who is not on that box: "no usable key" alone would
648 /// cost them the trip to go and find out which of the three it was. Same 738 /// cost them the trip to go and find out which of the three it was.
649 /// words `run` gives the same three refusals. 739 ///
740 /// Word for word what `run` prints for the same three refusals, path in
741 /// the same position — a key is rejected for the same reasons however the
742 /// daemon was asked, and server.zig's `endpoint_req` refusals honour that
743 /// literally too. One vocabulary, greppable across all three.
650 fn reportKeyRefusal(path: []const u8, err: anyerror) void { 744 fn reportKeyRefusal(path: []const u8, err: anyerror) void {
651 std.debug.print("muxd endpoint: {s}: {s}; staying on ssh\n", .{ path, switch (err) { 745 switch (err) {
652 error.KeyFileMissing => "no such key file", 746 error.KeyFileMissing => std.debug.print(
653 error.KeyFilePermissive => "readable by group or other, chmod 600 it", 747 "muxd endpoint: no such key file: {s}; staying on ssh\n",
654 error.KeyFileMalformed => "not a key: want 32 raw bytes or 64 hex characters", 748 .{path},
655 else => @errorName(err), 749 ),
656 } }); 750 error.KeyFilePermissive => std.debug.print(
751 "muxd endpoint: {s} is readable by group or other; chmod 600 it; staying on ssh\n",
752 .{path},
753 ),
754 error.KeyFileMalformed => std.debug.print(
755 "muxd endpoint: {s} is not a key: want 32 raw bytes or 64 hex characters; staying on ssh\n",
756 .{path},
757 ),
758 else => std.debug.print(
759 "muxd endpoint: cannot read {s}: {s}; staying on ssh\n",
760 .{ path, @errorName(err) },
761 ),
762 }
657 } 763 }
658 764
659 /// One observer round-trip: `endpoint_req`, then a bounded wait for the 765 /// One observer round-trip: `endpoint_req`, then a bounded wait for the
@@ -966,16 +1072,102 @@ test "parseArgs: endpoint is a command and takes --sock" {
966 try std.testing.expect(s.ok.cmd == .endpoint); 1072 try std.testing.expect(s.ok.cmd == .endpoint);
967 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?); 1073 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?);
968 1074
969 // `endpoint` is `proxy` plus a preamble, and `proxy` takes exactly one 1075 // An unknown flag is refused, as it is for every command: a client of
970 // flag. A second one arriving here would be a client of some later 1076 // some later version asking this binary for something it cannot do
971 // version asking for something this binary cannot do, and the refusal 1077 // gets a legible refusal rather than silence.
972 // is what makes that legible instead of silently ignored. 1078 //
1079 // Only UNKNOWN, though. The value-taking flags share one loop, so
1080 // `endpoint --cols 100` parses and is then ignored — `--sock` is the
1081 // only one this command reads. `keygen` is the sole verb that narrows
1082 // its own surface, and widening that rule to `proxy` and `endpoint`
1083 // together is its own change, not this one's.
973 try std.testing.expect(parse(&.{ "muxd", "endpoint", "--quiet" }).err == .unknown_arg); 1084 try std.testing.expect(parse(&.{ "muxd", "endpoint", "--quiet" }).err == .unknown_arg);
1085 try std.testing.expect(parse(&.{ "muxd", "endpoint", "--cols", "100" }) == .ok);
974 const missing = parse(&.{ "muxd", "endpoint", "--sock" }); 1086 const missing = parse(&.{ "muxd", "endpoint", "--sock" });
975 try std.testing.expect(missing.err == .missing_value); 1087 try std.testing.expect(missing.err == .missing_value);
976 try std.testing.expectEqualStrings("--sock", missing.err.missing_value); 1088 try std.testing.expectEqualStrings("--sock", missing.err.missing_value);
977 } 1089 }
978 1090
1091 test "announceKeyFrom: MUX_KEY_FILE wins, and the default it skipped is not created" {
1092 const testtmp = @import("testtmp");
1093 var tmp = try testtmp.TmpDir.make();
1094 defer tmp.cleanup();
1095
1096 var ebuf: [280]u8 = undefined;
1097 var dbuf: [280]u8 = undefined;
1098 const env = try std.fmt.bufPrint(&ebuf, "{s}/env-key", .{tmp.path()});
1099 const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()});
1100 try xdg.writeNewKey(env);
1101
1102 const r = announceKeyFrom(env, dflt);
1103 try std.testing.expect(r == .key);
1104 // The key it returned is the file it was pointed at, not merely some
1105 // key: the announce is only worth anything if it names the one the
1106 // daemon will authenticate with.
1107 var on_disk: [32]u8 = undefined;
1108 try std.testing.expectEqualSlices(u8, try std.fs.cwd().readFile(env, &on_disk), &r.key.bytes);
1109
1110 // The default is not merely unused, it is uncreated. Creating a key
1111 // beside one the user named would leave a credential nobody asked for
1112 // and, worse, one the daemon might later pick up instead.
1113 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(dflt, .{}));
1114 }
1115
1116 test "announceKeyFrom: the default is created when absent, and no path at all is no_path" {
1117 const testtmp = @import("testtmp");
1118 var tmp = try testtmp.TmpDir.make();
1119 defer tmp.cleanup();
1120
1121 var dbuf: [280]u8 = undefined;
1122 const dflt = try std.fmt.bufPrint(&dbuf, "{s}/cfg/mux/key", .{tmp.path()});
1123
1124 // The mosh-server move: a fresh box gets a key rather than a lecture.
1125 const made = announceKeyFrom(null, dflt);
1126 try std.testing.expect(made == .key);
1127 const st = try std.fs.cwd().statFile(dflt);
1128 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(st.mode & 0o777)));
1129
1130 // A second call loads the SAME key rather than rotating it: the
1131 // announce must name what the daemon will authenticate with, and this
1132 // process runs once per attach.
1133 const again = announceKeyFrom(null, dflt);
1134 try std.testing.expect(again == .key);
1135 try std.testing.expectEqualSlices(u8, &made.key.bytes, &again.key.bytes);
1136
1137 try std.testing.expect(announceKeyFrom(null, null) == .no_path);
1138 }
1139
1140 test "announceKeyFrom: a default that cannot be created reports the create, not the load" {
1141 const testtmp = @import("testtmp");
1142 var tmp = try testtmp.TmpDir.make();
1143 defer tmp.cleanup();
1144
1145 var robuf: [280]u8 = undefined;
1146 var dbuf: [280]u8 = undefined;
1147 const ro = try std.fmt.bufPrint(&robuf, "{s}/ro", .{tmp.path()});
1148 const dflt = try std.fmt.bufPrint(&dbuf, "{s}/mux/key", .{ro});
1149 try std.fs.cwd().makePath(ro);
1150 {
1151 var d = try std.fs.cwd().openDir(ro, .{ .iterate = true });
1152 defer d.close();
1153 try d.chmod(0o500);
1154 }
1155 // Left at 0500 for cleanup, deliberately: 0500 still grants read and
1156 // execute, so deleteTree can enter and list it, and removing the empty
1157 // directory itself needs write on the tmp ROOT, which is untouched.
1158 // Emptiness is not an assumption — it is the assertion below.
1159
1160 // Swallowing the create error leaves the load to speak, and all it can
1161 // say is `no such key file` — which names the symptom and sends
1162 // someone reading it over ssh to look for a file, when the real story
1163 // is a directory they cannot write. The distinction is the whole
1164 // reason the create's error is retained.
1165 const r = announceKeyFrom(null, dflt);
1166 try std.testing.expect(r == .create_failed);
1167 try std.testing.expectEqual(error.AccessDenied, r.create_failed.err);
1168 try std.testing.expectEqualStrings(dflt, r.create_failed.path);
1169 }
1170
979 test "askEndpointPort: a socket nobody serves answers 0, quickly" { 1171 test "askEndpointPort: a socket nobody serves answers 0, quickly" {
980 const testtmp = @import("testtmp"); 1172 const testtmp = @import("testtmp");
981 var tmp = try testtmp.TmpDir.make(); 1173 var tmp = try testtmp.TmpDir.make();
src/proxy.zig
Old New
@@ -6,6 +6,35 @@
6 const std = @import("std"); 6 const std = @import("std");
7 const TmpDir = @import("testtmp").TmpDir; 7 const TmpDir = @import("testtmp").TmpDir;
8 8
9 /// Make a hangup on any of this process's pipes surface as EPIPE from
10 /// write() instead of killing it.
11 ///
12 /// Defence in depth, not a fix: Zig's start.zig already installs a noop
13 /// SIGPIPE handler, so `pump`'s write-error returns are reachable without
14 /// this. What it pins is that the reachability belongs to this code instead
15 /// of to a std default (`std.options.keep_sigpipe`) another module could
16 /// flip.
17 ///
18 /// Exported because `muxd endpoint` writes its announce to the same stdout
19 /// this pump is about to use, before calling `run` — one installer rather
20 /// than a copy, so the two cannot drift. No protocol knowledge crosses the
21 /// boundary, which is the only thing this file's import list forbids.
22 ///
23 /// The one real difference from the std default: SIG_IGN survives exec, a
24 /// handler does not. So a caller that SPAWNS must install this after the
25 /// spawn, never before, or the child inherits the ignore across its exec —
26 /// which is why client.zig installs its ignore only after spawning the
27 /// transport child, why `muxd endpoint` calls this after its auto-start,
28 /// and why the order does not matter here (the proxy spawns nothing).
29 pub fn ignoreSigpipe() void {
30 var ign: std.posix.Sigaction = .{
31 .handler = .{ .handler = std.posix.SIG.IGN },
32 .mask = std.posix.sigemptyset(),
33 .flags = 0,
34 };
35 std.posix.sigaction(std.posix.SIG.PIPE, &ign, null);
36 }
37
9 /// `muxd proxy` proper: pump between this process's stdio and `sock_path`. 38 /// `muxd proxy` proper: pump between this process's stdio and `sock_path`.
10 pub fn run(sock_path: []const u8) !u8 { 39 pub fn run(sock_path: []const u8) !u8 {
11 return pump(std.posix.STDIN_FILENO, std.posix.STDOUT_FILENO, sock_path); 40 return pump(std.posix.STDIN_FILENO, std.posix.STDOUT_FILENO, sock_path);
@@ -23,21 +52,7 @@ pub fn pump(in_fd: std.posix.fd_t, out_fd: std.posix.fd_t, sock_path: []const u8
23 defer stream.close(); 52 defer stream.close();
24 const sock = stream.handle; 53 const sock = stream.handle;
25 54
26 // Defence in depth, not a fix: Zig's start.zig already installs a noop 55 ignoreSigpipe();
27 // SIGPIPE handler, so a hangup surfaces as EPIPE from write() rather than
28 // killing us, and the error returns below are reachable without this. What
29 // this pins is that the reachability belongs to this file instead of to a
30 // std default (`std.options.keep_sigpipe`) another module could flip. The
31 // one real difference between the two: SIG_IGN survives exec, a handler
32 // does not — which is why client.zig installs its ignore only after
33 // spawning the transport child, and why the order matters there and not
34 // here (the proxy spawns nothing).
35 var ign: std.posix.Sigaction = .{
36 .handler = .{ .handler = std.posix.SIG.IGN },
37 .mask = std.posix.sigemptyset(),
38 .flags = 0,
39 };
40 std.posix.sigaction(std.posix.SIG.PIPE, &ign, null);
41 56
42 var buf: [64 * 1024]u8 = undefined; 57 var buf: [64 * 1024]u8 = undefined;
43 while (true) { 58 while (true) {