a73x

c1dac3ce

fix: a verb that never attached does not blame the client table

a73x   2026-08-26 14:14

Commit message
fix: a verb that never attached does not blame the client table

`status` sends status_req down an observer connection and never attaches,
so its `exit_status 1` has exactly one producer: findSession missed. An
observer that cannot be seated is closed frameless by acceptConn, so no
table being full can reach that reply — yet the detail named `max_clients`
and sent the agent to read `muxd stats` over a name it mistyped.

The refusal now knows which ask it is answering. `capture` is not a
refusal path at all (an unknown name comes back inside `dump_reply`,
exit 0), verified against a real daemon.

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

src/muxa.zig
Old New
@@ -862,7 +862,7 @@ test "the refused-attach failure keeps the error+detail shape every failure has"
862 var buf: [512]u8 = undefined; 862 var buf: [512]u8 = undefined;
863 863
864 var fbs = std.io.fixedBufferStream(&buf); 864 var fbs = std.io.fixedBufferStream(&buf);
865 try writeAttachRefusedError(fbs.writer(), "nosuch"); 865 try writeAttachRefusedError(fbs.writer(), "nosuch", .attach);
866 // The name is in the detail because it is the one thing the agent got 866 // The name is in the detail because it is the one thing the agent got
867 // wrong, and an agent reading only `.error` still gets a phrase that 867 // wrong, and an agent reading only `.error` still gets a phrase that
868 // cannot be confused with a command's failure. 868 // cannot be confused with a command's failure.
@@ -877,10 +877,26 @@ test "the refused-attach failure keeps the error+detail shape every failure has"
877 // "": a detail reading `so must already exist` would send an agent 877 // "": a detail reading `so must already exist` would send an agent
878 // looking for a name it never typed. 878 // looking for a name it never typed.
879 var dflt = std.io.fixedBufferStream(&buf); 879 var dflt = std.io.fixedBufferStream(&buf);
880 try writeAttachRefusedError(dflt.writer(), ""); 880 try writeAttachRefusedError(dflt.writer(), "", .attach);
881 try std.testing.expect(std.mem.indexOf(u8, dflt.getWritten(), "so the default session must already exist") != null); 881 try std.testing.expect(std.mem.indexOf(u8, dflt.getWritten(), "so the default session must already exist") != null);
882 } 882 }
883 883
884 test "a refusal a full client table cannot have caused does not blame one" {
885 var buf: [512]u8 = undefined;
886 var fbs = std.io.fixedBufferStream(&buf);
887 // `status` never attaches, so `exit_status 1` here has one producer:
888 // findSession missed. An observer with no slot is closed frameless, so
889 // no fullness of any table can reach this reply — naming a constant
890 // that cannot be involved sends an agent to read `muxd stats`.
891 try writeAttachRefusedError(fbs.writer(), "nosuch", .query);
892 try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "so nosuch must already exist") != null);
893 try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "max_clients") == null);
894 try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "room for one more client") == null);
895 // The verb never attached, so the sentence must not open by calling
896 // this an attach either.
897 try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "refused this attach") == null);
898 }
899
884 /// The exit code for "the one JSON object never reached stdout". Distinct 900 /// The exit code for "the one JSON object never reached stdout". Distinct
885 /// from all four codes that mean it DID: an object plus 0/1/3, or a usage 901 /// from all four codes that mean it DID: an object plus 0/1/3, or a usage
886 /// error whose 2 promises stdout was left empty on purpose. 902 /// error whose 2 promises stdout was left empty on purpose.
@@ -1005,36 +1021,49 @@ fn writeSessionEndedError(writer: anytype, code: ?u8) !void {
1005 try writer.writeAll("}\n"); 1021 try writer.writeAll("}\n");
1006 } 1022 }
1007 1023
1024 /// Which ask the daemon refused. `status` and `capture` never attach, so
1025 /// their `exit_status 1` has exactly one producer — a name the daemon does
1026 /// not hold. An observer that cannot be seated is closed without a frame
1027 /// (`acceptConn`), so no table being full can reach them.
1028 const Refused = enum { attach, query };
1029
1008 /// `failSessionEnded` where the snapshot rule says refusal instead: an 1030 /// `failSessionEnded` where the snapshot rule says refusal instead: an
1009 /// agent told "session ended" goes looking for a shell that never ran. 1031 /// agent told "session ended" goes looking for a shell that never ran.
1010 fn failAttachRefused(name: []const u8) u8 { 1032 fn failAttachRefused(name: []const u8, ask: Refused) u8 {
1011 var buf: [1024]u8 = undefined; 1033 var buf: [1024]u8 = undefined;
1012 var fbs = std.io.fixedBufferStream(&buf); 1034 var fbs = std.io.fixedBufferStream(&buf);
1013 writeAttachRefusedError(fbs.writer(), name) catch return 1; 1035 writeAttachRefusedError(fbs.writer(), name, ask) catch return 1;
1014 return emit(fbs.getWritten(), 1); 1036 return emit(fbs.getWritten(), 1);
1015 } 1037 }
1016 1038
1017 /// The detail names both conditions because `exit_status 1` before a 1039 /// An attach's `exit_status 1` before a snapshot has two producers and the
1018 /// snapshot has more than one producer and the frame does not say which: 1040 /// frame does not say which: `resolveSession` refused the name (absent,
1019 /// server.zig's `resolveSession` refused the name (absent, since a 0x0 1041 /// since a 0x0 attach cannot create, or unspelled), or `freeClientSlot` had
1020 /// attach cannot create, or unspelled), or `freeClientSlot` had no seat 1042 /// no seat left. Naming only absence would print "no session 0" at a daemon
1021 /// left. Naming only absence would print "no session 0" at a daemon that 1043 /// that holds session 0 and is merely full — so the attach detail names
1022 /// holds session 0 and is merely full. A name too long to fit still leaves 1044 /// both, and the query detail names only the one that can reach it. A name
1023 /// a detail: losing the whole reply to a long argv is worse than losing 1045 /// too long to fit still leaves a detail: losing the whole reply to a long
1024 /// the name from it. 1046 /// argv is worse than losing the name from it.
1025 fn writeAttachRefusedError(writer: anytype, name: []const u8) !void { 1047 fn writeAttachRefusedError(writer: anytype, name: []const u8, ask: Refused) !void {
1026 const why = "the daemon refused this attach: muxa joins at 0x0 and never creates, so "; 1048 const why, const need = switch (ask) {
1027 const need = " must already exist and the daemon must have room for one more client (max_clients)"; 1049 .attach => .{
1050 "the daemon refused this attach: muxa joins at 0x0 and never creates, so ",
1051 " must already exist and the daemon must have room for one more client (max_clients)",
1052 },
1053 .query => .{
1054 "the daemon refused this query: muxa never creates a session, so ",
1055 " must already exist",
1056 },
1057 };
1028 try writer.writeAll("{\"error\":\"attach refused\",\"detail\":"); 1058 try writer.writeAll("{\"error\":\"attach refused\",\"detail\":");
1029 if (name.len == 0) { 1059 var buf: [768]u8 = undefined;
1030 // "" is the wire's default spelling, not a session with no name. 1060 // "" is the wire's default spelling, not a session with no name: a
1031 try jsonEscape(writer, why ++ "the default session" ++ need); 1061 // detail reading `so must already exist` sends an agent looking for a
1032 } else { 1062 // name it never typed.
1033 var buf: [768]u8 = undefined; 1063 const shown = if (name.len == 0) "the default session" else name;
1034 const line = std.fmt.bufPrint(&buf, "{s}{s}{s}", .{ why, name, need }) catch 1064 const line = std.fmt.bufPrint(&buf, "{s}{s}{s}", .{ why, shown, need }) catch
1035 why ++ "the session asked for" ++ need; 1065 std.fmt.bufPrint(&buf, "{s}the session asked for{s}", .{ why, need }) catch why;
1036 try jsonEscape(writer, line); 1066 try jsonEscape(writer, line);
1037 }
1038 try writer.writeAll("}\n"); 1067 try writer.writeAll("}\n");
1039 } 1068 }
1040 1069
@@ -1197,7 +1226,7 @@ fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, session: []const u8, deadli
1197 // only word this ask gets to say. 1226 // only word this ask gets to say.
1198 conn.sendFrame(.status_req, session, deadline) catch |e| return fail("status: send failed", @errorName(e)); 1227 conn.sendFrame(.status_req, session, deadline) catch |e| return fail("status: send failed", @errorName(e));
1199 const frame = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) { 1228 const frame = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) {
1200 error.AttachRefused => return failAttachRefused(session), 1229 error.AttachRefused => return failAttachRefused(session, .query),
1201 error.SessionExited => return failSessionEnded(conn.session_exit), 1230 error.SessionExited => return failSessionEnded(conn.session_exit),
1202 else => return fail("status: no reply", @errorName(e)), 1231 else => return fail("status: no reply", @errorName(e)),
1203 }; 1232 };
@@ -1283,7 +1312,7 @@ fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, session: []const
1283 const payload = proto.encodeDebugDumpNamed(&buf, vt, session); 1312 const payload = proto.encodeDebugDumpNamed(&buf, vt, session);
1284 conn.sendFrame(.debug_dump, payload, deadline) catch |e| return fail("capture: send failed", @errorName(e)); 1313 conn.sendFrame(.debug_dump, payload, deadline) catch |e| return fail("capture: send failed", @errorName(e));
1285 const frame = conn.awaitFrame(.dump_reply, deadline) catch |e| switch (e) { 1314 const frame = conn.awaitFrame(.dump_reply, deadline) catch |e| switch (e) {
1286 error.AttachRefused => return failAttachRefused(session), 1315 error.AttachRefused => return failAttachRefused(session, .query),
1287 error.SessionExited => return failSessionEnded(conn.session_exit), 1316 error.SessionExited => return failSessionEnded(conn.session_exit),
1288 else => return fail("capture: no reply", @errorName(e)), 1317 else => return fail("capture: no reply", @errorName(e)),
1289 }; 1318 };
@@ -1342,7 +1371,7 @@ fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, session: []
1342 // session's death rather than as "sent", because this verb's answer 1371 // session's death rather than as "sent", because this verb's answer
1343 // is about the send and there is no longer a session to have sent 1372 // is about the send and there is no longer a session to have sent
1344 // to — an agent that wants the death to be an ANSWER runs `run`. 1373 // to — an agent that wants the death to be an ANSWER runs `run`.
1345 error.AttachRefused => return failAttachRefused(session), 1374 error.AttachRefused => return failAttachRefused(session, .attach),
1346 error.SessionExited => return failSessionEnded(conn.session_exit), 1375 error.SessionExited => return failSessionEnded(conn.session_exit),
1347 else => return fail("send: daemon never acknowledged the input", @errorName(e)), 1376 else => return fail("send: daemon never acknowledged the input", @errorName(e)),
1348 }; 1377 };
@@ -1668,7 +1697,7 @@ fn awaitVerb(
1668 // never recorded, and the await would sit waiting for a return that 1697 // never recorded, and the await would sit waiting for a return that
1669 // had happened. 1698 // had happened.
1670 const since = currentSeq(alloc, conn, o.session, deadline) catch |e| switch (e) { 1699 const since = currentSeq(alloc, conn, o.session, deadline) catch |e| switch (e) {
1671 error.AttachRefused => return failAttachRefused(o.session), 1700 error.AttachRefused => return failAttachRefused(o.session, .attach),
1672 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), 1701 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)),
1673 else => return failAs(who, "status failed", @errorName(e)), 1702 else => return failAs(who, "status failed", @errorName(e)),
1674 }; 1703 };
@@ -1687,7 +1716,7 @@ fn awaitVerb(
1687 } 1716 }
1688 1717
1689 const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) { 1718 const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) {
1690 error.AttachRefused => return failAttachRefused(o.session), 1719 error.AttachRefused => return failAttachRefused(o.session, .attach),
1691 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), 1720 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)),
1692 else => { 1721 else => {
1693 var detail: [128]u8 = undefined; 1722 var detail: [128]u8 = undefined;
test/e2e.sh
Old New
@@ -8777,7 +8777,8 @@ wait_sock "$SOCK66" "$OUT.ref.d" "refused-attach daemon never bound"
8777 # refused" while still claiming a session ended would pass a one-sided grep. 8777 # refused" while still claiming a session ended would pass a one-sided grep.
8778 refuse_leg() { 8778 refuse_leg() {
8779 _out="$OUT.ref$1" 8779 _out="$OUT.ref$1"
8780 shift 8780 _kind="$2"
8781 shift 2
8781 set +e 8782 set +e
8782 XDG_STATE_HOME="$REFSTATE" timeout 20 "$MUXA" "$@" --sock "$SOCK66" \ 8783 XDG_STATE_HOME="$REFSTATE" timeout 20 "$MUXA" "$@" --sock "$SOCK66" \
8783 --session nosuch --timeout 5000 > "$_out" 2>&1 8784 --session nosuch --timeout 5000 > "$_out" 2>&1
@@ -8792,9 +8793,19 @@ refuse_leg() {
8792 grep -qF 'so nosuch must already exist' "$_out" || { 8793 grep -qF 'so nosuch must already exist' "$_out" || {
8793 echo "e2e FAIL: refused-attach: muxa $1 did not name the session asked for:" 8794 echo "e2e FAIL: refused-attach: muxa $1 did not name the session asked for:"
8794 cat "$_out"; exit 1; } 8795 cat "$_out"; exit 1; }
8795 grep -qF 'room for one more client' "$_out" || { 8796 # Only the verbs that attach have two causes. status and capture never
8796 echo "e2e FAIL: refused-attach: muxa $1 blamed absence for a refusal that has two causes:" 8797 # attach, so no client slot was ever asked for and none can be blamed:
8797 cat "$_out"; exit 1; } 8798 # an agent sent to read `muxd stats` over a name it mistyped burns a
8799 # round trip on a table that is not full.
8800 if [ "$_kind" = attach ]; then
8801 grep -qF 'room for one more client' "$_out" || {
8802 echo "e2e FAIL: refused-attach: muxa $1 blamed absence for a refusal that has two causes:"
8803 cat "$_out"; exit 1; }
8804 else
8805 grep -qF 'max_clients' "$_out" && {
8806 echo "e2e FAIL: refused-attach: muxa $1 never attached, so it must not blame the client table:"
8807 cat "$_out"; exit 1; }
8808 fi
8798 grep -qF 'session_ended' "$_out" && { 8809 grep -qF 'session_ended' "$_out" && {
8799 echo "e2e FAIL: refused-attach: muxa $1 still reports a session that ended:" 8810 echo "e2e FAIL: refused-attach: muxa $1 still reports a session that ended:"
8800 cat "$_out"; exit 1; } 8811 cat "$_out"; exit 1; }
@@ -8806,9 +8817,9 @@ refuse_leg() {
8806 cat "$_out"; exit 1; } 8817 cat "$_out"; exit 1; }
8807 return 0 8818 return 0
8808 } 8819 }
8809 refuse_leg run run true 8820 refuse_leg run attach run true
8810 refuse_leg status status 8821 refuse_leg status query status
8811 refuse_leg send send x 8822 refuse_leg send attach send x
8812 8823
8813 # ...and the daemon that said no three times is still serving. Refusing is 8824 # ...and the daemon that said no three times is still serving. Refusing is
8814 # an answer, not a fault: a daemon that died on a typed `--session` would 8825 # an answer, not a fault: a daemon that died on a typed `--session` would