a73x

2f43441a

dial.detach: the goodbye, for symmetry

a73x   2026-08-31 17:15

Commit message
dial.detach: the goodbye, for symmetry

`dial.zig` could say hello and ask a question; it could not say goodbye,
so every caller holding one of its connections spelled the frame itself.

The reach is narrower than the survey suggested, and the doc says so
rather than implying otherwise. All three production detaches write
through a carrier that may be a QUIC link — `client.Transport` in
`client.zig` and `mux a`'s agent connection in `muxa.zig` — and neither
holds a file descriptor to hand this. They frame the same goodbye
themselves. What is left is the connections `dial` itself returns: the
daemon suite's seven, and any embedder that links this module and `term`.
That is the right home for it anyway, since the hello lives here.

`server_test_quic.zig`'s detach stays as it is: it appends into a byte
buffer it is building for the QUIC path, not to a socket.

The new test is the only thing pinning the frame. Skipping a converted
site's detach leaves the suite green, because each block's `defer
c.close()` drops the client daemon-side just as well — so the frame's
shape is checked here, on a socketpair, where a peer can be read.

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

src/dial.zig
Old New
@@ -46,6 +46,25 @@ pub fn dialAttachNamed(sock_path: []const u8, cols: u16, rows: u16, name: []cons
46 return s; 46 return s;
47 } 47 }
48 48
49 /// The goodbye for a connection this module opened. Fire-and-forget by
50 /// design: there is no acknowledgement on the wire. The daemon answers a
51 /// `detach` by dropping the client and sending nothing back, and a client
52 /// that has said goodbye never redials — so the close that follows is final
53 /// rather than a network event to recover from. (The wall's `detach_ack` is
54 /// an intra-process atomic its pump sets for its own thread-join ordering.
55 /// No frame carries it.)
56 ///
57 /// This takes the fd rather than the `std.net.Stream`, because that is what
58 /// a caller which kept only the handle has, and every connection this module
59 /// hands back is one. Which is also the limit of its reach: the client's own
60 /// detach goes through `client.Transport` and `mux a`'s through that module's
61 /// agent connection, and either of those may be a QUIC link with no fd to
62 /// write to. Those carriers frame the same goodbye themselves; a wrapper over
63 /// a file descriptor is not what they hold.
64 pub fn detach(fd: std.posix.fd_t) !void {
65 return proto.writeFrame(fd, .detach, "");
66 }
67
49 /// Dial, ask one question, read the one answer, hang up. The observer verbs' 68 /// Dial, ask one question, read the one answer, hang up. The observer verbs'
50 /// round trip: `stats_req`, `sessions_req`, `debug_dump`, `endpoint_req`, 69 /// round trip: `stats_req`, `sessions_req`, `debug_dump`, `endpoint_req`,
51 /// `upgrade_req` — every one of them a question asked by a caller that holds 70 /// `upgrade_req` — every one of them a question asked by a caller that holds
@@ -131,6 +150,28 @@ test "the attach helpers fail at the dial, before any frame" {
131 try std.testing.expectError(error.FileNotFound, dialAttachNamed("/nonexistent-dir/mux-dial-test.sock", 80, 24, "work")); 150 try std.testing.expectError(error.FileNotFound, dialAttachNamed("/nonexistent-dir/mux-dial-test.sock", 80, 24, "work"));
132 } 151 }
133 152
153 test "detach writes one empty frame and waits for nothing" {
154 // Pinned on a socketpair because the goodbye has no reply to wait for:
155 // what is checkable is that exactly one empty `detach` reaches the peer
156 // and the call returns without reading anything back.
157 var pair: [2]i32 = undefined;
158 try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair));
159 defer std.posix.close(pair[1]);
160
161 try detach(pair[0]);
162 // Close the writing end — not deferred, since it must be shut before the
163 // reads below — so a second frame would show up as a second read rather
164 // than as a block.
165 std.posix.close(pair[0]);
166
167 const alloc = std.testing.allocator;
168 const frame = (try proto.readFrame(alloc, pair[1])).?;
169 defer frame.deinit(alloc);
170 try std.testing.expectEqual(proto.MsgType.detach, frame.type);
171 try std.testing.expectEqual(@as(usize, 0), frame.payload.len);
172 try std.testing.expect((try proto.readFrame(alloc, pair[1])) == null);
173 }
174
134 test "ask: a path nothing is bound at is error.NoDaemon, not a connect errno" { 175 test "ask: a path nothing is bound at is error.NoDaemon, not a connect errno" {
135 try std.testing.expectError( 176 try std.testing.expectError(
136 error.NoDaemon, 177 error.NoDaemon,
src/server/server_test_attach.zig
Old New
@@ -907,7 +907,7 @@ test "Server: reattach needs a recent have_seq AND this daemon's epoch to get a
907 try std.testing.expect(last_seq > 0); 907 try std.testing.expect(last_seq > 0);
908 // 0 is reserved for "I hold no epoch", so a live daemon never has it. 908 // 0 is reserved for "I hold no epoch", so a live daemon never has it.
909 try std.testing.expect(epoch != 0); 909 try std.testing.expect(epoch != 0);
910 try proto.writeFrame(c1.handle, .detach, ""); 910 try dial.detach(c1.handle);
911 } 911 }
912 912
913 // Session 2: the seq is real but the epoch belongs to some other daemon 913 // Session 2: the seq is real but the epoch belongs to some other daemon
@@ -925,7 +925,7 @@ test "Server: reattach needs a recent have_seq AND this daemon's epoch to get a
925 // seq session 1 learned. 925 // seq session 1 learned.
926 last_seq = first.?.seq; 926 last_seq = first.?.seq;
927 try std.testing.expectEqual(epoch, first.?.epoch); 927 try std.testing.expectEqual(epoch, first.?.epoch);
928 try proto.writeFrame(c2.handle, .detach, ""); 928 try dial.detach(c2.handle);
929 } 929 }
930 930
931 // Session 3: a real seq with have_epoch = 0 — a pre-epoch client, or one 931 // Session 3: a real seq with have_epoch = 0 — a pre-epoch client, or one
@@ -938,7 +938,7 @@ test "Server: reattach needs a recent have_seq AND this daemon's epoch to get a
938 try std.testing.expect(first != null); 938 try std.testing.expect(first != null);
939 try std.testing.expectEqual(proto.MsgType.snapshot, first.?.type); 939 try std.testing.expectEqual(proto.MsgType.snapshot, first.?.type);
940 last_seq = first.?.seq; 940 last_seq = first.?.seq;
941 try proto.writeFrame(c3.handle, .detach, ""); 941 try dial.detach(c3.handle);
942 } 942 }
943 943
944 // Session 4: right seq, right epoch — we really are up to date, so the 944 // Session 4: right seq, right epoch — we really are up to date, so the
@@ -950,7 +950,7 @@ test "Server: reattach needs a recent have_seq AND this daemon's epoch to get a
950 const first = try firstStateFrame(alloc, c4.handle, 10_000); 950 const first = try firstStateFrame(alloc, c4.handle, 10_000);
951 try std.testing.expect(first != null); 951 try std.testing.expect(first != null);
952 try std.testing.expectEqual(proto.MsgType.delta, first.?.type); 952 try std.testing.expectEqual(proto.MsgType.delta, first.?.type);
953 try proto.writeFrame(c4.handle, .detach, ""); 953 try dial.detach(c4.handle);
954 } 954 }
955 955
956 // Session 5: right epoch, but have_seq predates the last discontinuity, 956 // Session 5: right epoch, but have_seq predates the last discontinuity,
@@ -991,7 +991,7 @@ test "Server: a daemon restart invalidates have_seq even with the old epoch pres
991 held_a = try drainHeld(alloc, c.handle, 10_000); 991 held_a = try drainHeld(alloc, c.handle, 10_000);
992 try std.testing.expect(held_a.seq > 0); 992 try std.testing.expect(held_a.seq > 0);
993 try std.testing.expect(held_a.epoch != 0); 993 try std.testing.expect(held_a.epoch != 0);
994 try proto.writeFrame(c.handle, .detach, ""); 994 try dial.detach(c.handle);
995 } 995 }
996 // A is gone: thread joined, deinit ran, socket unlinked. 996 // A is gone: thread joined, deinit ran, socket unlinked.
997 997
@@ -1016,7 +1016,7 @@ test "Server: a daemon restart invalidates have_seq even with the old epoch pres
1016 try std.testing.expect(held_b.seq > 0); 1016 try std.testing.expect(held_b.seq > 0);
1017 try std.testing.expect(held_b.epoch != 0); 1017 try std.testing.expect(held_b.epoch != 0);
1018 try std.testing.expect(held_b.epoch != held_a.epoch); 1018 try std.testing.expect(held_b.epoch != held_a.epoch);
1019 try proto.writeFrame(c.handle, .detach, ""); 1019 try dial.detach(c.handle);
1020 } 1020 }
1021 1021
1022 // A seq B issued moments ago, quoted back with A's epoch. Everything 1022 // A seq B issued moments ago, quoted back with A's epoch. Everything
@@ -1031,7 +1031,7 @@ test "Server: a daemon restart invalidates have_seq even with the old epoch pres
1031 try std.testing.expectEqual(proto.MsgType.snapshot, first.?.type); 1031 try std.testing.expectEqual(proto.MsgType.snapshot, first.?.type);
1032 // Read off the wire, not off srv_b: the server thread is live. 1032 // Read off the wire, not off srv_b: the server thread is live.
1033 try std.testing.expectEqual(held_b.epoch, first.?.epoch); 1033 try std.testing.expectEqual(held_b.epoch, first.?.epoch);
1034 try proto.writeFrame(c.handle, .detach, ""); 1034 try dial.detach(c.handle);
1035 } 1035 }
1036 1036
1037 // And the literal restart case: the exact pair the pre-restart client 1037 // And the literal restart case: the exact pair the pre-restart client