c1dac3ce
fix: a verb that never attached does not blame the client table
a73x 2026-08-26 14:14
Commit message
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 |