a73x

6ba139fd

refactor: one owner for the error+detail failure object

a73x   2026-08-29 10:01

Commit message
refactor: one owner for the error+detail failure object

`writeAttachRefusedError` built `{"error":...,"detail":...}` itself, which
is exactly what `writeError` builds for every other failure. It now returns
just the detail sentence (`refusedDetail`) and `failAttachRefused` hands
that to `fail` — so the object's shape, its escaping and its
too-long-to-report fallback have one producer, which is what `fail`'s own
contract already claimed.

Nothing on the wire moves: same words, same `emit(..., 1)`. The
`{"error":"attach refused","detail":` prefix stays pinned end to end by
the verbSend capture test and by e2e_13_birth; the two unit tests move to
the detail with their subject.

Not folded, and why: the `verbStatus`/`verbCapture` prologue (ledger #20's
other half). A shared send-and-await helper needs a two-arm result union
and a seven-argument signature, which costs more product lines than the
two seven-line prologues it would replace — and the three other
AttachRefused sites answer with `.attach` and `reportSessionEnded`, so
they cannot use it. Measured, not assumed: −6 for the half that pays.

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