a73x

d51f6359

One round trip asks a Transport a question

a73x   2026-08-31 16:43

Commit message
One round trip asks a Transport a question

`birthSession` and `listSessions` each spelled the same skeleton: write a
frame, then poll-and-read until the answer arrives or the budget is spent.
`roundTrip` is that skeleton once, beside `awaitFrames` (the bounded wait it
already shared), and the QUIC caveat — one readable event is one frame on a
socket link, so a second read would block — now has one copy rather than two
that could drift.

The copies were not identical, and the difference was a latent hang: the
poll's loop `continue`d on a frame it did not want, which on an fd link is a
second BLOCKING read that no deadline bounds. The birth's shape (return to
the poll unless the link is QUIC) is the correct one and is what both now
get.

What genuinely differs stays at the call sites, where it is the caller's
story to tell: `roundTrip` reports a peer that hung up as `error.Closed`,
which the birth reads as the daemon's refusal and the poll as a box it
cannot reach. The birth names two wanted types, `.snapshot` and
`.exit_status`, because both end its wait — waiting past a refusal for a
snapshot that is not coming would spend the whole budget on an answer
already given.

One narrowing: a write that fails now leaves both functions as
`error.Transport` instead of raw `error.BrokenPipe`, which is what
`birthSession`'s own header already promised ("`Transport` a wire that
broke"). Unit tests for both (1056/1056, 74 steps) and e2e 13_birth and
09_hosts pass unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

src/client/client.zig
Old New
@@ -1183,6 +1183,44 @@ fn awaitFrames(tr: *Transport, deadline: i64) !bool {
1183 return fds[0].revents != 0 or tr.link == .quic; 1183 return fds[0].revents != 0 or tr.link == .quic;
1184 } 1184 }
1185 1185
1186 /// One question on a side connection: send `req`, then hand back the first
1187 /// frame whose type is one the caller named, deinit-ing every other frame
1188 /// the daemon says on the way. The frame returned is the caller's to deinit.
1189 ///
1190 /// The errors are thin on purpose — `Timeout` for a budget spent, `Closed`
1191 /// for a peer that hung up before answering, `Transport` for a poll that
1192 /// failed, `OutOfMemory` for this machine's allocator. What one MEANS is
1193 /// the caller's to say: the same close is a refusal to a birth and a box
1194 /// that is down to a poll.
1195 fn roundTrip(
1196 tr: *Transport,
1197 alloc: std.mem.Allocator,
1198 req: proto.MsgType,
1199 payload: []const u8,
1200 want: []const proto.MsgType,
1201 deadline: i64,
1202 ) !proto.Frame {
1203 try tr.writeFrame(req, payload);
1204 while (true) {
1205 if (!try awaitFrames(tr, deadline)) continue;
1206 while (true) {
1207 // `readFrame` turns every wire fault into `.closed`, so the one
1208 // error it can still raise is this machine's own allocator.
1209 switch (try tr.readFrame(alloc)) {
1210 .incomplete => break,
1211 .closed => return error.Closed,
1212 .frame => |f| {
1213 for (want) |w| if (f.type == w) return f;
1214 f.deinit(alloc);
1215 },
1216 }
1217 // One readable event is one frame on a socket link, and a
1218 // second read would block until the daemon spoke again.
1219 if (tr.link != .quic) break;
1220 }
1221 }
1222 }
1223
1186 /// Creates `name` on `target` over a connection of its own, then leaves. 1224 /// Creates `name` on `target` over a connection of its own, then leaves.
1187 pub fn birthSession( 1225 pub fn birthSession(
1188 alloc: std.mem.Allocator, 1226 alloc: std.mem.Allocator,
@@ -1201,38 +1239,27 @@ pub fn birthSession(
1201 var buf: [proto.attach_max_len]u8 = undefined; 1239 var buf: [proto.attach_max_len]u8 = undefined;
1202 // Resume args 0/0: this connection holds nothing and wants the 1240 // Resume args 0/0: this connection holds nothing and wants the
1203 // cheapest thing the daemon can answer with. 1241 // cheapest thing the daemon can answer with.
1204 try tr.writeFrame(.attach, proto.encodeAttachNamed(&buf, cols, rows, 0, 0, proto.wireName(name))); 1242 const attach = proto.encodeAttachNamed(&buf, cols, rows, 0, 0, proto.wireName(name));
1205 const deadline = std.time.milliTimestamp() + birth_budget_ms; 1243 const deadline = std.time.milliTimestamp() + birth_budget_ms;
1206 while (true) { 1244 // Both answers are asked for, because both end the wait:
1207 if (!try awaitFrames(&tr, deadline)) continue; 1245 // `server_sessions.resolve` refuses with an `exit_status` before any
1208 while (true) { 1246 // snapshot, and waiting past it for a snapshot that is not coming would
1209 // Not `Refused`: a read that failed is a transport that 1247 // spend the whole budget on an answer already given.
1210 // broke, and telling the browser its attach was refused for 1248 const f = roundTrip(&tr, alloc, .attach, attach, &.{ .snapshot, .exit_status }, deadline) catch |e| return switch (e) {
1211 // something nothing refused is a lie the page then shows. 1249 // A daemon that hung up before it answered refused this attach.
1212 switch (tr.readFrame(alloc) catch return error.Transport) { 1250 error.Closed => error.Refused,
1213 .incomplete => break, 1251 error.Timeout => error.Timeout,
1214 .closed => return error.Refused, 1252 // Not `Refused`: a read that failed is a transport that broke, and
1215 .frame => |f| { 1253 // telling the browser its attach was refused for something nothing
1216 defer f.deinit(alloc); 1254 // refused is a lie the page then shows.
1217 switch (f.type) { 1255 else => error.Transport,
1218 // The daemon made a grid, so the session exists. 1256 };
1219 .snapshot => { 1257 defer f.deinit(alloc);
1220 tr.writeFrame(.detach, "") catch {}; 1258 if (f.type != .snapshot) return error.Refused;
1221 return; 1259 // The daemon made a grid, so the session exists — and this connection
1222 }, 1260 // leaves, because the session outlives it and a lingering client would
1223 // `server_sessions.resolve` refuses before any 1261 // hold a slot the browser tile needs.
1224 // snapshot; anything else is narration this 1262 tr.writeFrame(.detach, "") catch {};
1225 // connection has no use for.
1226 .exit_status => return error.Refused,
1227 else => {},
1228 }
1229 },
1230 }
1231 // One readable event is one frame on a socket link, and a
1232 // second read would block until the daemon spoke again.
1233 if (tr.link != .quic) break;
1234 }
1235 }
1236 } 1263 }
1237 1264
1238 /// Every way of not reaching a daemon, except running out of memory. 1265 /// Every way of not reaching a daemon, except running out of memory.
@@ -1266,27 +1293,17 @@ pub fn listSessions(
1266 defer tr.close(); 1293 defer tr.close();
1267 // The handoff picks its own link, so only the success case knows it. 1294 // The handoff picks its own link, so only the success case knows it.
1268 if (answered) |a| a.* = tr.link; 1295 if (answered) |a| a.* = tr.link;
1269 try tr.writeFrame(.sessions_req, "");
1270 const deadline = std.time.milliTimestamp() + budget_ms; 1296 const deadline = std.time.milliTimestamp() + budget_ms;
1271 while (true) { 1297 // A daemon too old for the verb answers nothing at all, so the timeout is
1272 if (!try awaitFrames(&tr, deadline)) continue; 1298 // what names it; anything else on this connection is narration the poll
1273 while (true) { 1299 // never asked for, which `roundTrip` drops. A daemon that hung up is a
1274 switch (tr.readFrame(alloc) catch |e| return oomOrTransport(e)) { 1300 // box the wall cannot reach, the same as one that never came up.
1275 .incomplete => break, 1301 const f = roundTrip(&tr, alloc, .sessions_req, "", &.{.sessions_reply}, deadline) catch |e|
1276 .closed => return error.Transport, 1302 return if (e == error.Timeout) e else oomOrTransport(e);
1277 .frame => |f| { 1303 defer f.deinit(alloc);
1278 defer f.deinit(alloc); 1304 if (f.payload.len > out.len) return error.Transport;
1279 // A daemon too old for the verb answers nothing at all, 1305 @memcpy(out[0..f.payload.len], f.payload);
1280 // so the timeout is what names it; anything else on this 1306 return out[0..f.payload.len];
1281 // connection is narration it never asked for.
1282 if (f.type != .sessions_reply) continue;
1283 if (f.payload.len > out.len) return error.Transport;
1284 @memcpy(out[0..f.payload.len], f.payload);
1285 return out[0..f.payload.len];
1286 },
1287 }
1288 }
1289 }
1290 } 1307 }
1291 1308
1292 /// A daemon on a wall: what to dial, and the line that named it. The 1309 /// A daemon on a wall: what to dial, and the line that named it. The