6ba139fd
refactor: one owner for the error+detail failure object
a73x 2026-08-29 10:01
Commit message
src/cli/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -957,43 +957,39 @@ test "a refusal that closes the socket before the input write is still reported | |||
| 957 | try std.testing.expectEqualStrings(want, buf[0..@min(n, want.len)]); | 957 | try std.testing.expectEqualStrings(want, buf[0..@min(n, want.len)]); |
| 958 | } | 958 | } |
| 959 | 959 | ||
| 960 | test "the refused-attach failure keeps the error+detail shape every failure has" { | 960 | test "the refused-attach detail names the session and both of the refusal's producers" { |
| 961 | var buf: [512]u8 = undefined; | 961 | var buf: [768]u8 = undefined; |
| 962 | 962 | ||
| 963 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 964 | try writeAttachRefusedError(fbs.writer(), "nosuch", .attach); | ||
| 965 | // The name is in the detail because it is the one thing the agent got | 963 | // The name is in the detail because it is the one thing the agent got |
| 966 | // wrong, and an agent reading only `.error` still gets a phrase that | 964 | // wrong; the `{"error":"attach refused","detail":` wrapper around it is |
| 967 | // cannot be confused with a command's failure. | 965 | // `fail`'s, pinned through a real verb by the capture test above. |
| 968 | try std.testing.expect(std.mem.startsWith(u8, fbs.getWritten(), "{\"error\":\"attach refused\",\"detail\":")); | 966 | const named = refusedDetail(&buf, "nosuch", .attach); |
| 969 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "so nosuch must already exist") != null); | 967 | try std.testing.expect(std.mem.indexOf(u8, named, "so nosuch must already exist") != null); |
| 970 | // Both conditions, never just absence: the daemon sends the same | 968 | // Both conditions, never just absence: the daemon sends the same |
| 971 | // refusal when its client table is full, and a detail claiming the | 969 | // refusal when its client table is full, and a detail claiming the |
| 972 | // session does not exist would be a lie at a daemon that holds it. | 970 | // session does not exist would be a lie at a daemon that holds it. |
| 973 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "room for one more client") != null); | 971 | try std.testing.expect(std.mem.indexOf(u8, named, "room for one more client") != null); |
| 974 | 972 | ||
| 975 | // The empty name is the wire's default spelling, not a session called | 973 | // The empty name is the wire's default spelling, not a session called |
| 976 | // "": a detail reading `so must already exist` would send an agent | 974 | // "": a detail reading `so must already exist` would send an agent |
| 977 | // looking for a name it never typed. | 975 | // looking for a name it never typed. |
| 978 | var dflt = std.io.fixedBufferStream(&buf); | 976 | const dflt = refusedDetail(&buf, "", .attach); |
| 979 | try writeAttachRefusedError(dflt.writer(), "", .attach); | 977 | try std.testing.expect(std.mem.indexOf(u8, dflt, "so the default session must already exist") != null); |
| 980 | try std.testing.expect(std.mem.indexOf(u8, dflt.getWritten(), "so the default session must already exist") != null); | ||
| 981 | } | 978 | } |
| 982 | 979 | ||
| 983 | test "a refusal a full client table cannot have caused does not blame one" { | 980 | test "a refusal a full client table cannot have caused does not blame one" { |
| 984 | var buf: [512]u8 = undefined; | 981 | var buf: [768]u8 = undefined; |
| 985 | var fbs = std.io.fixedBufferStream(&buf); | ||
| 986 | // `status` never attaches, so `exit_status 1` here has one producer: | 982 | // `status` never attaches, so `exit_status 1` here has one producer: |
| 987 | // findSession missed. An observer with no slot is closed frameless, so | 983 | // findSession missed. An observer with no slot is closed frameless, so |
| 988 | // no fullness of any table can reach this reply — naming a constant | 984 | // no fullness of any table can reach this reply — naming a constant |
| 989 | // that cannot be involved sends an agent to read `mux d stats`. | 985 | // that cannot be involved sends an agent to read `mux d stats`. |
| 990 | try writeAttachRefusedError(fbs.writer(), "nosuch", .query); | 986 | const q = refusedDetail(&buf, "nosuch", .query); |
| 991 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "so nosuch must already exist") != null); | 987 | try std.testing.expect(std.mem.indexOf(u8, q, "so nosuch must already exist") != null); |
| 992 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "max_clients") == null); | 988 | try std.testing.expect(std.mem.indexOf(u8, q, "max_clients") == null); |
| 993 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "room for one more client") == null); | 989 | try std.testing.expect(std.mem.indexOf(u8, q, "room for one more client") == null); |
| 994 | // The verb never attached, so the sentence must not open by calling | 990 | // The verb never attached, so the sentence must not open by calling |
| 995 | // this an attach either. | 991 | // this an attach either. |
| 996 | try std.testing.expect(std.mem.indexOf(u8, fbs.getWritten(), "refused this attach") == null); | 992 | try std.testing.expect(std.mem.indexOf(u8, q, "refused this attach") == null); |
| 997 | } | 993 | } |
| 998 | 994 | ||
| 999 | /// The exit code for "the one JSON object never reached stdout". Distinct | 995 | /// The exit code for "the one JSON object never reached stdout". Distinct |
| @@ -1129,10 +1125,8 @@ const Refused = enum { attach, query }; | |||
| 1129 | /// `failSessionEnded` where the snapshot rule says refusal instead: an | 1125 | /// `failSessionEnded` where the snapshot rule says refusal instead: an |
| 1130 | /// agent told "session ended" goes looking for a shell that never ran. | 1126 | /// agent told "session ended" goes looking for a shell that never ran. |
| 1131 | fn failAttachRefused(name: []const u8, ask: Refused) u8 { | 1127 | fn failAttachRefused(name: []const u8, ask: Refused) u8 { |
| 1132 | var buf: [1024]u8 = undefined; | 1128 | var buf: [768]u8 = undefined; |
| 1133 | var fbs = std.io.fixedBufferStream(&buf); | 1129 | return fail("attach refused", refusedDetail(&buf, name, ask)); |
| 1134 | writeAttachRefusedError(fbs.writer(), name, ask) catch return 1; | ||
| 1135 | return emit(fbs.getWritten(), 1); | ||
| 1136 | } | 1130 | } |
| 1137 | 1131 | ||
| 1138 | /// A send raises the same refusal `awaitFrame` does, so it reaches the same | 1132 | /// A send raises the same refusal `awaitFrame` does, so it reaches the same |
| @@ -1150,7 +1144,7 @@ fn failSend(e: anyerror, name: []const u8, ask: Refused, who: []const u8, msg: [ | |||
| 1150 | /// both, and the query detail names only the one that can reach it. A name | 1144 | /// both, and the query detail names only the one that can reach it. A name |
| 1151 | /// too long to fit still leaves a detail: losing the whole reply to a long | 1145 | /// too long to fit still leaves a detail: losing the whole reply to a long |
| 1152 | /// argv is worse than losing the name from it. | 1146 | /// argv is worse than losing the name from it. |
| 1153 | fn writeAttachRefusedError(writer: anytype, name: []const u8, ask: Refused) !void { | 1147 | fn refusedDetail(buf: *[768]u8, name: []const u8, ask: Refused) []const u8 { |
| 1154 | const why, const need = switch (ask) { | 1148 | const why, const need = switch (ask) { |
| 1155 | .attach => .{ | 1149 | .attach => .{ |
| 1156 | "the daemon refused this attach: `mux a` joins at 0x0 and never creates, so ", | 1150 | "the daemon refused this attach: `mux a` joins at 0x0 and never creates, so ", |
| @@ -1161,16 +1155,12 @@ fn writeAttachRefusedError(writer: anytype, name: []const u8, ask: Refused) !voi | |||
| 1161 | " must already exist", | 1155 | " must already exist", |
| 1162 | }, | 1156 | }, |
| 1163 | }; | 1157 | }; |
| 1164 | try writer.writeAll("{\"error\":\"attach refused\",\"detail\":"); | ||
| 1165 | var buf: [768]u8 = undefined; | ||
| 1166 | // "" is the wire's default spelling, not a session with no name: a | 1158 | // "" is the wire's default spelling, not a session with no name: a |
| 1167 | // detail reading `so must already exist` sends an agent looking for a | 1159 | // detail reading `so must already exist` sends an agent looking for a |
| 1168 | // name it never typed. | 1160 | // name it never typed. |
| 1169 | const shown = if (name.len == 0) "the default session" else name; | 1161 | const shown = if (name.len == 0) "the default session" else name; |
| 1170 | const line = std.fmt.bufPrint(&buf, "{s}{s}{s}", .{ why, shown, need }) catch | 1162 | return std.fmt.bufPrint(buf, "{s}{s}{s}", .{ why, shown, need }) catch |
| 1171 | std.fmt.bufPrint(&buf, "{s}the session asked for{s}", .{ why, need }) catch why; | 1163 | std.fmt.bufPrint(buf, "{s}the session asked for{s}", .{ why, need }) catch why; |
| 1172 | try jsonEscape(writer, line); | ||
| 1173 | try writer.writeAll("}\n"); | ||
| 1174 | } | 1164 | } |
| 1175 | 1165 | ||
| 1176 | test "the session-ended failure keeps the error+detail shape every failure has" { | 1166 | test "the session-ended failure keeps the error+detail shape every failure has" { |
| @@ -1196,10 +1186,8 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 1196 | defer arena_state.deinit(); | 1186 | defer arena_state.deinit(); |
| 1197 | const alloc = arena_state.allocator(); | 1187 | const alloc = arena_state.allocator(); |
| 1198 | 1188 | ||
| 1199 | // Help and version go to stdout — the one place this mode otherwise | 1189 | // This mode's constraint, which `exitFor` is keeping: stdout is one |
| 1200 | // reserves for JSON, and the only two verbs allowed to spend it. The | 1190 | // JSON object per invocation, argument errors included. |
| 1201 | // usage is a diagnostic and goes to stderr, so stdout stays strictly one | ||
| 1202 | // JSON object per invocation even on the argument-error path. | ||
| 1203 | const o = parseArgs(args) catch |e| return cliflags.exitFor(e, usage, "mux", build_options.version); | 1191 | const o = parseArgs(args) catch |e| return cliflags.exitFor(e, usage, "mux", build_options.version); |
| 1204 | 1192 | ||
| 1205 | // Started BEFORE the connect, not after: over QUIC the handshake is | 1193 | // Started BEFORE the connect, not after: over QUIC the handshake is |