a73x

753c63d8

feat: a daemon lives until muxd stop — emptiness is not an exit

a73x   2026-08-28 19:53

Commit message
feat: a daemon lives until muxd stop — emptiness is not an exit

`x` ends a session, never a box. `SessionTable.reap` answered the last
dead shell's code and `pumpOnce` handed it to `run`, so ending the last
session took the daemon with it — and a host emptied that way was gone
before anyone could be born into it again. reap and pumpOnce now answer
nothing at all: a shell's exit code is a fact about that shell, delivered
to that shell's own clients as `exit_status`, and an emptied table is a
daemon with nothing on it rather than one that is leaving.

`muxd run` therefore exits 0 on every shutdown, `muxd stop` and a
supervisor's SIGTERM alike — a nonzero exit on a clean stop is what
systemd reads as a crash — and nonzero only for the boot failures main
picks before anything binds. The 130 that stood for SIGINT went with it.

Three e2e legs waited for a daemon to follow its last session out; they
stop it by name now, and agent.sh keeps the tear daemon's pid so the leak
sweep still waits for its verdict. The manifest path already coped with
an empty table — an upgrade test now says so, since an empty daemon is an
ordinary state rather than an unreachable one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/cli/main.zig
Old New
@@ -543,6 +543,13 @@ fn resumeRun(alloc: std.mem.Allocator, o: Opts, resume_fd: std.posix.fd_t) !u8 {
543 return try srv.run(); 543 return try srv.run();
544 } 544 }
545 545
546 /// `muxd run`: the daemon, in the foreground. Every code this function picks
547 /// is 1, and every one of them is a boot failure — an operator mistake caught
548 /// before anything bound. Reaching `srv.run()` means the daemon served, and
549 /// it answers 0 whenever something asks it to stop. No session's exit is ever
550 /// reported here: a shell's code goes to that shell's own clients
551 /// (`exit_status`), and an emptied daemon is one with nothing on it rather
552 /// than one that is leaving.
546 fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 { 553 fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
547 // Address and key are settled before anything binds: a mistyped address 554 // Address and key are settled before anything binds: a mistyped address
548 // or an unreadable key must not first leave a session socket and a live 555 // or an unreadable key must not first leave a session socket and a live
src/server.zig
Old New
@@ -998,11 +998,12 @@ pub const Server = struct {
998 } 998 }
999 999
1000 /// One poll iteration. A session whose shell has exited is torn down 1000 /// One poll iteration. A session whose shell has exited is torn down
1001 /// first — its clients told and dropped — and the pump carries on; only 1001 /// first — its clients told and dropped — and the pump carries on. It
1002 /// the LAST session's exit ends it, returning that shell's code. Null 1002 /// answers nothing: no session's death ends the daemon, not even the
1003 /// while any session lives. 1003 /// last one's, so an emptied daemon keeps serving its socket and a
1004 pub fn pumpOnce(self: *Server, timeout_ms: i32) !?u8 { 1004 /// fresh attach is born into it.
1005 if (self.sessions.reap(self)) |code| return code; 1005 pub fn pumpOnce(self: *Server, timeout_ms: i32) !void {
1006 self.sessions.reap(self);
1006 1007
1007 const listener_idx = max_sessions; 1008 const listener_idx = max_sessions;
1008 const client_base = max_sessions + 1; 1009 const client_base = max_sessions + 1;
@@ -1186,10 +1187,13 @@ pub const Server = struct {
1186 // handling above, or everything queued during this pump would wait 1187 // handling above, or everything queued during this pump would wait
1187 // for the next one and every reply would cost a poll cycle. 1188 // for the next one and every reply would cost a poll cycle.
1188 if (self.quicListener()) |q| q.drainAll(); 1189 if (self.quicListener()) |q| q.drainAll();
1189
1190 return null;
1191 } 1190 }
1192 1191
1192 /// Pumps until something asks the daemon to stop. The only nonzero exit
1193 /// `muxd run` has is a boot failure in main; getting here means the
1194 /// daemon served, so the answer is 0 whether the ask was `muxd stop` or
1195 /// a SIGTERM from a supervisor — which reads a nonzero exit on a clean
1196 /// shutdown as a crash.
1193 pub fn run(self: *Server) !u8 { 1197 pub fn run(self: *Server) !u8 {
1194 // The pump cannot start on a listener whose handler points anywhere 1198 // The pump cannot start on a listener whose handler points anywhere
1195 // but here. An adopted listener (initFromManifest) is built before 1199 // but here. An adopted listener (initFromManifest) is built before
@@ -1197,7 +1201,7 @@ pub const Server = struct {
1197 // caller already wired is the same assignment twice. 1201 // caller already wired is the same assignment twice.
1198 if (self.quicListener()) |l| l.setHandler(self.quicHandler()); 1202 if (self.quicListener()) |l| l.setHandler(self.quicHandler());
1199 while (true) { 1203 while (true) {
1200 if (shutdown_flag.load(.acquire)) return 130; 1204 if (shutdown_flag.load(.acquire)) return 0;
1201 // An upgrade was accepted: the reply has drained (pumpOnce 1205 // An upgrade was accepted: the reply has drained (pumpOnce
1202 // ran the observer handler), so exec now. Shaped like 1206 // ran the observer handler), so exec now. Shaped like
1203 // shutdown_flag but per-instance because the exec carries the 1207 // shutdown_flag but per-instance because the exec carries the
@@ -1207,7 +1211,7 @@ pub const Server = struct {
1207 // execUpgrade only returns on failure; the daemon carries on. 1211 // execUpgrade only returns on failure; the daemon carries on.
1208 continue; 1212 continue;
1209 } 1213 }
1210 if (try self.pumpOnce(100)) |code| return code; 1214 try self.pumpOnce(100);
1211 } 1215 }
1212 } 1216 }
1213 1217
src/server_sessions.zig
Old New
@@ -184,11 +184,11 @@ pub const SessionTable = struct {
184 } 184 }
185 185
186 /// Tear down every session whose shell has exited: its clients told and 186 /// Tear down every session whose shell has exited: its clients told and
187 /// dropped, its slot nulled, its name freed. Returns the daemon's exit 187 /// dropped, its slot nulled, its name freed. Answers nothing, because a
188 /// code — the last dead shell's — once no session remains; null while 188 /// shell's exit code is a fact about that shell and never about the
189 /// any lives. 189 /// daemon: an emptied table is a daemon with nothing on it, not a daemon
190 pub fn reap(self: *SessionTable, srv: *Server) ?u8 { 190 /// that is leaving. `muxd stop` is the end (decisions.md).
191 var last_code: ?u8 = null; 191 pub fn reap(self: *SessionTable, srv: *Server) void {
192 for (&self.table, 0..) |*slot, si| { 192 for (&self.table, 0..) |*slot, si| {
193 const s = if (slot.*) |*sp| sp else continue; 193 const s = if (slot.*) |*sp| sp else continue;
194 const exited = s.pty.checkExited(); 194 const exited = s.pty.checkExited();
@@ -263,23 +263,8 @@ pub const SessionTable = struct {
263 // to be told. 263 // to be told.
264 srv.agents.closeOfSession(srv, si); 264 srv.agents.closeOfSession(srv, si);
265 slot.* = null; 265 slot.* = null;
266 last_code = @intCast(code & 0xff);
267 } 266 }
268 } 267 }
269 // Recomputed, never counted as we went. `drainPending` above runs
270 // the QUIC read path, which reaches handleFrame → resolve →
271 // create: a session can therefore be BORN inside this loop,
272 // and if it lands in a slot the loop has already walked, an
273 // incremental counter never sees it. The daemon would then exit
274 // with a shell it forked moments earlier still running. Re-reading
275 // the table costs nothing and cannot be wrong.
276 if (self.live() == 0) {
277 // Nested rather than one `and`: no live sessions and no code
278 // means there were never any — unreachable after init, but this
279 // shape keeps it from ever being misread as exit 0.
280 if (last_code) |code| return code;
281 }
282 return null;
283 } 268 }
284 269
285 /// The `sessions_reply` payload: live names, '\n'-separated, slot order. 270 /// The `sessions_reply` payload: live names, '\n'-separated, slot order.
src/server_test_agent.zig
Old New
@@ -82,7 +82,7 @@ test "Server: a reaped session takes its agent socket with it" {
82 try proto.writeAllFd(srv.ses(0).pty.master, "die\n"); 82 try proto.writeAllFd(srv.ses(0).pty.master, "die\n");
83 var reaped = false; 83 var reaped = false;
84 for (0..600) |_| { 84 for (0..600) |_| {
85 _ = srv.pumpOnce(5) catch break; 85 srv.pumpOnce(5) catch break;
86 if (srv.sessions.table[0] == null) { 86 if (srv.sessions.table[0] == null) {
87 reaped = true; 87 reaped = true;
88 break; 88 break;
@@ -110,7 +110,7 @@ test "Server: agent_offer flags the slot, and an unknown type leaves the client
110 defer c.close(); 110 defer c.close();
111 try attachNamed(c.handle, 80, 24, ""); 111 try attachNamed(c.handle, 80, 24, "");
112 var spun: usize = 0; 112 var spun: usize = 0;
113 while (spun < 200 and srv.clients[0] == null) : (spun += 1) _ = try srv.pumpOnce(5); 113 while (spun < 200 and srv.clients[0] == null) : (spun += 1) try srv.pumpOnce(5);
114 try std.testing.expect(srv.clients[0] != null); 114 try std.testing.expect(srv.clients[0] != null);
115 // The offer is opt-in, so the flag must start false or every client 115 // The offer is opt-in, so the flag must start false or every client
116 // would look like it had offered. 116 // would look like it had offered.
@@ -118,7 +118,7 @@ test "Server: agent_offer flags the slot, and an unknown type leaves the client
118 118
119 try proto.writeFrame(c.handle, .agent_offer, ""); 119 try proto.writeFrame(c.handle, .agent_offer, "");
120 spun = 0; 120 spun = 0;
121 while (spun < 200 and !srv.clients[0].?.agent_offer) : (spun += 1) _ = try srv.pumpOnce(5); 121 while (spun < 200 and !srv.clients[0].?.agent_offer) : (spun += 1) try srv.pumpOnce(5);
122 try std.testing.expect(srv.clients[0].?.agent_offer); 122 try std.testing.expect(srv.clients[0].?.agent_offer);
123 123
124 // 0x7e is unmapped in MsgType. Skipping it rather than dropping the 124 // 0x7e is unmapped in MsgType. Skipping it rather than dropping the
@@ -148,7 +148,7 @@ test "Server: agent_offer flags the slot, and an unknown type leaves the client
148 fn pumpUntilReadable(srv: *Server, fd: std.posix.fd_t, buf: []u8, iters: usize) !?usize { 148 fn pumpUntilReadable(srv: *Server, fd: std.posix.fd_t, buf: []u8, iters: usize) !?usize {
149 var i: usize = 0; 149 var i: usize = 0;
150 while (i < iters) : (i += 1) { 150 while (i < iters) : (i += 1) {
151 _ = try srv.pumpOnce(5); 151 try srv.pumpOnce(5);
152 var pfd = [_]std.posix.pollfd{ 152 var pfd = [_]std.posix.pollfd{
153 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, 153 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
154 }; 154 };
@@ -165,11 +165,11 @@ fn pumpUntilReadable(srv: *Server, fd: std.posix.fd_t, buf: []u8, iters: usize)
165 fn attachOffering(srv: *Server, fd: std.posix.fd_t, slot: usize, name: []const u8) !void { 165 fn attachOffering(srv: *Server, fd: std.posix.fd_t, slot: usize, name: []const u8) !void {
166 try attachNamed(fd, 80, 24, name); 166 try attachNamed(fd, 80, 24, name);
167 var spun: usize = 0; 167 var spun: usize = 0;
168 while (spun < 200 and srv.clients[slot] == null) : (spun += 1) _ = try srv.pumpOnce(5); 168 while (spun < 200 and srv.clients[slot] == null) : (spun += 1) try srv.pumpOnce(5);
169 if (srv.clients[slot] == null) return error.ClientNeverSeated; 169 if (srv.clients[slot] == null) return error.ClientNeverSeated;
170 try proto.writeFrame(fd, .agent_offer, ""); 170 try proto.writeFrame(fd, .agent_offer, "");
171 spun = 0; 171 spun = 0;
172 while (spun < 200 and !srv.clients[slot].?.agent_offer) : (spun += 1) _ = try srv.pumpOnce(5); 172 while (spun < 200 and !srv.clients[slot].?.agent_offer) : (spun += 1) try srv.pumpOnce(5);
173 if (!srv.clients[slot].?.agent_offer) return error.OfferNeverLanded; 173 if (!srv.clients[slot].?.agent_offer) return error.OfferNeverLanded;
174 } 174 }
175 175
@@ -225,14 +225,14 @@ test "Server: a session with no agent socket does not inherit the daemon's" {
225 defer c.close(); 225 defer c.close();
226 try attachNamed(c.handle, 80, 24, "nosock"); 226 try attachNamed(c.handle, 80, 24, "nosock");
227 var spun: usize = 0; 227 var spun: usize = 0;
228 while (spun < 200 and srv.clients[0] == null) : (spun += 1) _ = try srv.pumpOnce(5); 228 while (spun < 200 and srv.clients[0] == null) : (spun += 1) try srv.pumpOnce(5);
229 const si = srv.clients[0].?.session orelse return error.ClientNeverSeated; 229 const si = srv.clients[0].?.session orelse return error.ClientNeverSeated;
230 try std.testing.expect(srv.sessions.table[si].?.agent_path == null); 230 try std.testing.expect(srv.sessions.table[si].?.agent_path == null);
231 231
232 try proto.writeFrame(c.handle, .input, cmd); 232 try proto.writeFrame(c.handle, .input, cmd);
233 spun = 0; 233 spun = 0;
234 while (spun < 600) : (spun += 1) { 234 while (spun < 600) : (spun += 1) {
235 _ = try srv.pumpOnce(5); 235 try srv.pumpOnce(5);
236 std.fs.accessAbsolute(done, .{}) catch continue; 236 std.fs.accessAbsolute(done, .{}) catch continue;
237 break; 237 break;
238 } 238 }
@@ -269,7 +269,7 @@ test "Server: a full channel table refuses the newest dial and says so once" {
269 defer for (dials) |d| d.close(); 269 defer for (dials) |d| d.close();
270 270
271 var spun: usize = 0; 271 var spun: usize = 0;
272 while (spun < 400 and srv.agents.refused_full == 0) : (spun += 1) _ = try srv.pumpOnce(5); 272 while (spun < 400 and srv.agents.refused_full == 0) : (spun += 1) try srv.pumpOnce(5);
273 for (srv.agents.chans) |slot| try std.testing.expect(slot != null); 273 for (srv.agents.chans) |slot| try std.testing.expect(slot != null);
274 try std.testing.expectEqual(@as(u32, 1), srv.agents.refused_full); 274 try std.testing.expectEqual(@as(u32, 1), srv.agents.refused_full);
275 try std.testing.expectEqual(@as(u32, 0), srv.agents.refused_no_offer); 275 try std.testing.expectEqual(@as(u32, 0), srv.agents.refused_no_offer);
@@ -296,7 +296,7 @@ test "Server: a full channel table refuses the newest dial and says so once" {
296 const again = try std.net.connectUnixSocket(path); 296 const again = try std.net.connectUnixSocket(path);
297 defer again.close(); 297 defer again.close();
298 spun = 0; 298 spun = 0;
299 while (spun < 400 and srv.agents.refused_full < 2) : (spun += 1) _ = try srv.pumpOnce(5); 299 while (spun < 400 and srv.agents.refused_full < 2) : (spun += 1) try srv.pumpOnce(5);
300 try std.testing.expectEqual(@as(u32, 2), srv.agents.refused_full); 300 try std.testing.expectEqual(@as(u32, 2), srv.agents.refused_full);
301 try std.testing.expect(srv.agents.full_said); 301 try std.testing.expect(srv.agents.full_said);
302 srv.agents.closeChan(&srv, 0, .notify); 302 srv.agents.closeChan(&srv, 0, .notify);
@@ -318,7 +318,7 @@ test "Server: an agent connection with nobody offering is refused fast" {
318 defer c.close(); 318 defer c.close();
319 try attachNamed(c.handle, 80, 24, ""); 319 try attachNamed(c.handle, 80, 24, "");
320 var spun: usize = 0; 320 var spun: usize = 0;
321 while (spun < 200 and srv.clients[0] == null) : (spun += 1) _ = try srv.pumpOnce(5); 321 while (spun < 200 and srv.clients[0] == null) : (spun += 1) try srv.pumpOnce(5);
322 try std.testing.expect(srv.clients[0] != null); 322 try std.testing.expect(srv.clients[0] != null);
323 // Attached but never offered — the case this test is about. The socket 323 // Attached but never offered — the case this test is about. The socket
324 // exists for as long as the session does; only the answer comes and goes. 324 // exists for as long as the session does; only the answer comes and goes.
@@ -431,7 +431,7 @@ test "Server: an agent connection is routed to the latest-active offerer" {
431 try proto.writeFrame(cb.handle, .input, "x"); 431 try proto.writeFrame(cb.handle, .input, "x");
432 var spun: usize = 0; 432 var spun: usize = 0;
433 while (spun < 200 and srv.clients[1].?.activity < srv.clients[0].?.activity) : (spun += 1) { 433 while (spun < 200 and srv.clients[1].?.activity < srv.clients[0].?.activity) : (spun += 1) {
434 _ = try srv.pumpOnce(5); 434 try srv.pumpOnce(5);
435 } 435 }
436 try std.testing.expect(srv.clients[1].?.activity > srv.clients[0].?.activity); 436 try std.testing.expect(srv.clients[1].?.activity > srv.clients[0].?.activity);
437 437
@@ -454,7 +454,7 @@ test "Server: an agent connection is routed to the latest-active offerer" {
454 try proto.writeFrame(ca.handle, .input, "y"); 454 try proto.writeFrame(ca.handle, .input, "y");
455 spun = 0; 455 spun = 0;
456 while (spun < 200 and srv.clients[0].?.activity < srv.clients[1].?.activity) : (spun += 1) { 456 while (spun < 200 and srv.clients[0].?.activity < srv.clients[1].?.activity) : (spun += 1) {
457 _ = try srv.pumpOnce(5); 457 try srv.pumpOnce(5);
458 } 458 }
459 try std.testing.expect(srv.clients[0].?.activity > srv.clients[1].?.activity); 459 try std.testing.expect(srv.clients[0].?.activity > srv.clients[1].?.activity);
460 460
@@ -726,7 +726,7 @@ test "Server: a QUIC client's agent channels die with the client" {
726 cl.drain(); 726 cl.drain();
727 727
728 var only = [_]*quic_server.TestClient{&cl}; 728 var only = [_]*quic_server.TestClient{&cl};
729 _ = try quicPump(&srv, &only, 10000, &srv, struct { 729 try quicPump(&srv, &only, 10000, &srv, struct {
730 fn f(s: *Server) bool { 730 fn f(s: *Server) bool {
731 return s.clients[0] != null and s.clients[0].?.agent_offer; 731 return s.clients[0] != null and s.clients[0].?.agent_offer;
732 } 732 }
@@ -736,7 +736,7 @@ test "Server: a QUIC client's agent channels die with the client" {
736 const path = srv.ses(0).agent_path orelse return error.NoAgentSocket; 736 const path = srv.ses(0).agent_path orelse return error.NoAgentSocket;
737 const agent = try std.net.connectUnixSocket(path); 737 const agent = try std.net.connectUnixSocket(path);
738 defer agent.close(); 738 defer agent.close();
739 _ = try quicPump(&srv, &only, 10000, &cl, struct { 739 try quicPump(&srv, &only, 10000, &cl, struct {
740 fn f(t: *quic_server.TestClient) bool { 740 fn f(t: *quic_server.TestClient) bool {
741 return findFrame(t.recv_buf[0..t.recv_len], .agent_open) != null; 741 return findFrame(t.recv_buf[0..t.recv_len], .agent_open) != null;
742 } 742 }
src/server_test_attach.zig
Old New
@@ -1347,7 +1347,7 @@ test "Server: a client that sends half a frame does not stall the pump" {
1347 1347
1348 // One pump with a 20ms poll must come back on its own — the readable 1348 // One pump with a 20ms poll must come back on its own — the readable
1349 // socket is serviced, the partial frame is held, and the loop returns. 1349 // socket is serviced, the partial frame is held, and the loop returns.
1350 _ = try srv.pumpOnce(20); 1350 try srv.pumpOnce(20);
1351 rescue.stop.store(true, .release); 1351 rescue.stop.store(true, .release);
1352 if (rescue.fired.load(.acquire)) { 1352 if (rescue.fired.load(.acquire)) {
1353 std.debug.print("pumpOnce blocked on a half frame; the rescue thread had to complete it\n", .{}); 1353 std.debug.print("pumpOnce blocked on a half frame; the rescue thread had to complete it\n", .{});
@@ -1358,7 +1358,7 @@ test "Server: a client that sends half a frame does not stall the pump" {
1358 1358
1359 // The rest arrives; the frame completes on the next pump. 1359 // The rest arrives; the frame completes on the next pump.
1360 try proto.writeAllFd(c.peer, frame.items[3..]); 1360 try proto.writeAllFd(c.peer, frame.items[3..]);
1361 _ = try srv.pumpOnce(20); 1361 try srv.pumpOnce(20);
1362 try std.testing.expectEqual(@as(u16, 100), srv.colsNow(0)); 1362 try std.testing.expectEqual(@as(u16, 100), srv.colsNow(0));
1363 try std.testing.expectEqual(@as(u16, 30), srv.rowsNow(0)); 1363 try std.testing.expectEqual(@as(u16, 30), srv.rowsNow(0));
1364 try std.testing.expectEqual(@as(usize, 0), srv.clients[0].?.inbound.items.len); 1364 try std.testing.expectEqual(@as(usize, 0), srv.clients[0].?.inbound.items.len);
@@ -1555,7 +1555,7 @@ test "Server: attaching seats a client in activity order, and typing or resizing
1555 defer ca.close(); 1555 defer ca.close();
1556 try attachNamed(ca.handle, 80, 24, ""); 1556 try attachNamed(ca.handle, 80, 24, "");
1557 var spun: usize = 0; 1557 var spun: usize = 0;
1558 while (spun < 200 and srv.clients[0] == null) : (spun += 1) _ = try srv.pumpOnce(5); 1558 while (spun < 200 and srv.clients[0] == null) : (spun += 1) try srv.pumpOnce(5);
1559 1559
1560 // Seated one at a time so the slot indices below are the attach order: 1560 // Seated one at a time so the slot indices below are the attach order:
1561 // freeClientSlot hands out the lowest free slot. 1561 // freeClientSlot hands out the lowest free slot.
@@ -1563,7 +1563,7 @@ test "Server: attaching seats a client in activity order, and typing or resizing
1563 defer cb.close(); 1563 defer cb.close();
1564 try attachNamed(cb.handle, 80, 24, ""); 1564 try attachNamed(cb.handle, 80, 24, "");
1565 spun = 0; 1565 spun = 0;
1566 while (spun < 200 and srv.clients[1] == null) : (spun += 1) _ = try srv.pumpOnce(5); 1566 while (spun < 200 and srv.clients[1] == null) : (spun += 1) try srv.pumpOnce(5);
1567 1567
1568 const a_attached = (srv.clients[0] orelse return error.ClientANeverSeated).activity; 1568 const a_attached = (srv.clients[0] orelse return error.ClientANeverSeated).activity;
1569 const b_attached = (srv.clients[1] orelse return error.ClientBNeverSeated).activity; 1569 const b_attached = (srv.clients[1] orelse return error.ClientBNeverSeated).activity;
@@ -1574,7 +1574,7 @@ test "Server: attaching seats a client in activity order, and typing or resizing
1574 try proto.writeFrame(ca.handle, .input, "x"); 1574 try proto.writeFrame(ca.handle, .input, "x");
1575 spun = 0; 1575 spun = 0;
1576 while (spun < 200 and srv.clients[0].?.activity == a_attached) : (spun += 1) { 1576 while (spun < 200 and srv.clients[0].?.activity == a_attached) : (spun += 1) {
1577 _ = try srv.pumpOnce(5); 1577 try srv.pumpOnce(5);
1578 } 1578 }
1579 try std.testing.expect(srv.clients[0].?.activity > srv.clients[1].?.activity); 1579 try std.testing.expect(srv.clients[0].?.activity > srv.clients[1].?.activity);
1580 1580
@@ -1584,7 +1584,7 @@ test "Server: attaching seats a client in activity order, and typing or resizing
1584 try proto.writeFrame(cb.handle, .resize, &proto.encodeSize(100, 30)); 1584 try proto.writeFrame(cb.handle, .resize, &proto.encodeSize(100, 30));
1585 spun = 0; 1585 spun = 0;
1586 while (spun < 200 and srv.clients[1].?.activity < a_typed) : (spun += 1) { 1586 while (spun < 200 and srv.clients[1].?.activity < a_typed) : (spun += 1) {
1587 _ = try srv.pumpOnce(5); 1587 try srv.pumpOnce(5);
1588 } 1588 }
1589 try std.testing.expect(srv.clients[1].?.activity > srv.clients[0].?.activity); 1589 try std.testing.expect(srv.clients[1].?.activity > srv.clients[0].?.activity);
1590 } 1590 }
@@ -1646,7 +1646,7 @@ test "Server: an observer that sends one byte does not stall the pump, and finis
1646 // would seat `muxd stats`. 1646 // would seat `muxd stats`.
1647 const obs = try std.net.connectUnixSocket(sock_path); 1647 const obs = try std.net.connectUnixSocket(sock_path);
1648 defer obs.close(); 1648 defer obs.close();
1649 _ = try srv.pumpOnce(20); // accept 1649 try srv.pumpOnce(20); // accept
1650 try std.testing.expect(srv.observers[0] != null); 1650 try std.testing.expect(srv.observers[0] != null);
1651 1651
1652 var frame: std.ArrayList(u8) = .empty; 1652 var frame: std.ArrayList(u8) = .empty;
@@ -1657,7 +1657,7 @@ test "Server: an observer that sends one byte does not stall the pump, and finis
1657 var rescue: HalfFrameRescue = .{ .peer = obs.handle, .rest = frame.items[1..] }; 1657 var rescue: HalfFrameRescue = .{ .peer = obs.handle, .rest = frame.items[1..] };
1658 const th = try std.Thread.spawn(.{}, HalfFrameRescue.run, .{&rescue}); 1658 const th = try std.Thread.spawn(.{}, HalfFrameRescue.run, .{&rescue});
1659 defer th.join(); 1659 defer th.join();
1660 _ = try srv.pumpOnce(20); 1660 try srv.pumpOnce(20);
1661 rescue.stop.store(true, .release); 1661 rescue.stop.store(true, .release);
1662 if (rescue.fired.load(.acquire)) { 1662 if (rescue.fired.load(.acquire)) {
1663 std.debug.print("pumpOnce blocked on a one-byte observer; the rescue thread had to complete it\n", .{}); 1663 std.debug.print("pumpOnce blocked on a one-byte observer; the rescue thread had to complete it\n", .{});
@@ -1700,13 +1700,13 @@ test "Server: stalled observers are dropped on the idle deadline, and the table
1700 for (&peers) |*p| { 1700 for (&peers) |*p| {
1701 p.* = try std.net.connectUnixSocket(sock_path); 1701 p.* = try std.net.connectUnixSocket(sock_path);
1702 try proto.writeAllFd(p.handle, &[_]u8{@intFromEnum(proto.MsgType.stats_req)}); 1702 try proto.writeAllFd(p.handle, &[_]u8{@intFromEnum(proto.MsgType.stats_req)});
1703 _ = try srv.pumpOnce(20); 1703 try srv.pumpOnce(20);
1704 } 1704 }
1705 defer for (peers) |p| p.close(); 1705 defer for (peers) |p| p.close();
1706 for (srv.observers) |o| try std.testing.expect(o != null); 1706 for (srv.observers) |o| try std.testing.expect(o != null);
1707 1707
1708 std.Thread.sleep(60 * std.time.ns_per_ms); 1708 std.Thread.sleep(60 * std.time.ns_per_ms);
1709 _ = try srv.pumpOnce(20); 1709 try srv.pumpOnce(20);
1710 for (srv.observers) |o| try std.testing.expect(o == null); 1710 for (srv.observers) |o| try std.testing.expect(o == null);
1711 // Dropped means closed: the peer reads EOF, not a hang. 1711 // Dropped means closed: the peer reads EOF, not a hang.
1712 var b: [1]u8 = undefined; 1712 var b: [1]u8 = undefined;
@@ -1750,7 +1750,7 @@ test "Server: bytes after an attach in the same write reach the promoted client"
1750 return error.NoSnapshotAfterAttach; 1750 return error.NoSnapshotAfterAttach;
1751 first.deinit(alloc); 1751 first.deinit(alloc);
1752 var i: usize = 0; 1752 var i: usize = 0;
1753 while (i < 100 and srv.colsNow(0) != 132) : (i += 1) _ = try srv.pumpOnce(5); 1753 while (i < 100 and srv.colsNow(0) != 132) : (i += 1) try srv.pumpOnce(5);
1754 try std.testing.expectEqual(@as(u16, 132), srv.colsNow(0)); 1754 try std.testing.expectEqual(@as(u16, 132), srv.colsNow(0));
1755 try std.testing.expectEqual(@as(u16, 50), srv.rowsNow(0)); 1755 try std.testing.expectEqual(@as(u16, 50), srv.rowsNow(0));
1756 // Nothing left behind on either side of the promotion. 1756 // Nothing left behind on either side of the promotion.
src/server_test_await.zig
Old New
@@ -107,7 +107,7 @@ fn anyReturnWithin(
107 ) !?proto.CmdState { 107 ) !?proto.CmdState {
108 const deadline = std.time.milliTimestamp() + budget_ms; 108 const deadline = std.time.milliTimestamp() + budget_ms;
109 while (std.time.milliTimestamp() < deadline) { 109 while (std.time.milliTimestamp() < deadline) {
110 _ = try srv.pumpOnce(5); 110 try srv.pumpOnce(5);
111 var pfd = [_]std.posix.pollfd{ 111 var pfd = [_]std.posix.pollfd{
112 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, 112 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
113 }; 113 };
@@ -555,7 +555,7 @@ test "Server: an await is held open, answered by a mark, and re-answered immedia
555 .settle_ms = 0, 555 .settle_ms = 0,
556 .timeout_ms = 5000, 556 .timeout_ms = 5000,
557 })); 557 }));
558 _ = try srv.pumpOnce(5); 558 try srv.pumpOnce(5);
559 const f2 = (try readQueued(alloc, c.handle, .await_reply)) orelse 559 const f2 = (try readQueued(alloc, c.handle, .await_reply)) orelse
560 return error.AwaitNotAnsweredOnTheSamePump; 560 return error.AwaitNotAnsweredOnTheSamePump;
561 defer f2.deinit(alloc); 561 defer f2.deinit(alloc);
@@ -625,7 +625,7 @@ test "Server: a return is still answerable once the next command is running" {
625 try proto.writeFrame(c.handle, .input, "go\n"); 625 try proto.writeFrame(c.handle, .input, "go\n");
626 var spun: usize = 0; 626 var spun: usize = 0;
627 while (spun < 500 and srv.sessions.table[0].?.cmd.phase != .running) : (spun += 1) { 627 while (spun < 500 and srv.sessions.table[0].?.cmd.phase != .running) : (spun += 1) {
628 _ = try srv.pumpOnce(5); 628 try srv.pumpOnce(5);
629 } 629 }
630 try std.testing.expectEqual(proto.CmdPhase.running, srv.sessions.table[0].?.cmd.phase); 630 try std.testing.expectEqual(proto.CmdPhase.running, srv.sessions.table[0].?.cmd.phase);
631 631
@@ -863,7 +863,7 @@ test "Server: re-attaching to another session drops the await it left behind" {
863 defer c.close(); 863 defer c.close();
864 try attachNamed(c.handle, 80, 24, "a"); 864 try attachNamed(c.handle, 80, 24, "a");
865 var spun: usize = 0; 865 var spun: usize = 0;
866 while (spun < 200 and srv.sessions.find("a") == null) : (spun += 1) _ = try srv.pumpOnce(5); 866 while (spun < 200 and srv.sessions.find("a") == null) : (spun += 1) try srv.pumpOnce(5);
867 const si_a = srv.sessions.find("a") orelse return error.NoSessionA; 867 const si_a = srv.sessions.find("a") orelse return error.NoSessionA;
868 868
869 // An await is a question about ONE session's seq series: since_seq is a 869 // An await is a question about ONE session's seq series: since_seq is a
@@ -875,7 +875,7 @@ test "Server: re-attaching to another session drops the await it left behind" {
875 })); 875 }));
876 spun = 0; 876 spun = 0;
877 while (spun < 200) : (spun += 1) { 877 while (spun < 200) : (spun += 1) {
878 _ = try srv.pumpOnce(5); 878 try srv.pumpOnce(5);
879 if (slotAwaiting(&srv, si_a)) break; 879 if (slotAwaiting(&srv, si_a)) break;
880 } 880 }
881 try std.testing.expect(slotAwaiting(&srv, si_a)); 881 try std.testing.expect(slotAwaiting(&srv, si_a));
@@ -885,7 +885,7 @@ test "Server: re-attaching to another session drops the await it left behind" {
885 // last_return — an await that answers instantly or never, arbitrarily. 885 // last_return — an await that answers instantly or never, arbitrarily.
886 try attachNamed(c.handle, 80, 24, "b"); 886 try attachNamed(c.handle, 80, 24, "b");
887 spun = 0; 887 spun = 0;
888 while (spun < 200 and srv.sessions.find("b") == null) : (spun += 1) _ = try srv.pumpOnce(5); 888 while (spun < 200 and srv.sessions.find("b") == null) : (spun += 1) try srv.pumpOnce(5);
889 const si_b = srv.sessions.find("b") orelse return error.NoSessionB; 889 const si_b = srv.sessions.find("b") orelse return error.NoSessionB;
890 try std.testing.expect(si_a != si_b); 890 try std.testing.expect(si_a != si_b);
891 891
@@ -1025,7 +1025,7 @@ test "Server: an await resolves against the awaiting client's session" {
1025 try proto.writeFrame(b.handle, .input, "go\n"); 1025 try proto.writeFrame(b.handle, .input, "go\n");
1026 var spun: usize = 0; 1026 var spun: usize = 0;
1027 while (spun < 500 and srv.sessions.table[si_b].?.last_return == null) : (spun += 1) { 1027 while (spun < 500 and srv.sessions.table[si_b].?.last_return == null) : (spun += 1) {
1028 _ = try srv.pumpOnce(5); 1028 try srv.pumpOnce(5);
1029 } 1029 }
1030 try std.testing.expect(srv.sessions.table[si_b].?.last_return != null); 1030 try std.testing.expect(srv.sessions.table[si_b].?.last_return != null);
1031 1031
@@ -1070,7 +1070,7 @@ test "Server: a promoted-but-unattached slot receives nothing" {
1070 // emits: grid updates from the echo, and the mode poll's first report. 1070 // emits: grid updates from the echo, and the mode poll's first report.
1071 try proto.writeAllFd(srv.sessions.table[0].?.pty.master, "say-something\n"); 1071 try proto.writeAllFd(srv.sessions.table[0].?.pty.master, "say-something\n");
1072 var i: usize = 0; 1072 var i: usize = 0;
1073 while (i < 40) : (i += 1) _ = try srv.pumpOnce(5); 1073 while (i < 40) : (i += 1) try srv.pumpOnce(5);
1074 1074
1075 // Liveness: the grid really moved, so silence below is the filter at 1075 // Liveness: the grid really moved, so silence below is the filter at
1076 // work and not a session that never spoke. 1076 // work and not a session that never spoke.
src/server_test_clipboard.zig
Old New
@@ -21,7 +21,7 @@ fn awaitSelectionReply(
21 ) !?struct { id: u32, status: proto.SelectionStatus, text_len: usize } { 21 ) !?struct { id: u32, status: proto.SelectionStatus, text_len: usize } {
22 var rounds: usize = 0; 22 var rounds: usize = 0;
23 while (rounds < 400) : (rounds += 1) { 23 while (rounds < 400) : (rounds += 1) {
24 _ = try srv.pumpOnce(5); 24 try srv.pumpOnce(5);
25 var pfd = [_]std.posix.pollfd{ 25 var pfd = [_]std.posix.pollfd{
26 .{ .fd = peer, .events = std.posix.POLL.IN, .revents = 0 }, 26 .{ .fd = peer, .events = std.posix.POLL.IN, .revents = 0 },
27 }; 27 };
@@ -271,7 +271,7 @@ fn clipboardIntoGap(
271 a.close(); 271 a.close();
272 var gone = false; 272 var gone = false;
273 for (0..400) |_| { 273 for (0..400) |_| {
274 _ = try srv.pumpOnce(5); 274 try srv.pumpOnce(5);
275 if (!srv.hasClientsIn(0)) { 275 if (!srv.hasClientsIn(0)) {
276 gone = true; 276 gone = true;
277 break; 277 break;
@@ -338,7 +338,7 @@ fn collectGapReplay(alloc: std.mem.Allocator, srv: *Server, fd: std.posix.fd_t)
338 var after: usize = 0; 338 var after: usize = 0;
339 while (i < 400 and after < 60) : (i += 1) { 339 while (i < 400 and after < 60) : (i += 1) {
340 if (out.content != null) after += 1; 340 if (out.content != null) after += 1;
341 _ = try srv.pumpOnce(5); 341 try srv.pumpOnce(5);
342 var pfd = [_]std.posix.pollfd{ 342 var pfd = [_]std.posix.pollfd{
343 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, 343 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
344 }; 344 };
@@ -709,7 +709,7 @@ test "Server: a session that dies holding a pending event frees it" {
709 try proto.writeAllFd(srv.sessions.table[0].?.pty.master, "die\n"); 709 try proto.writeAllFd(srv.sessions.table[0].?.pty.master, "die\n");
710 var reaped = false; 710 var reaped = false;
711 for (0..600) |_| { 711 for (0..600) |_| {
712 _ = srv.pumpOnce(5) catch break; 712 srv.pumpOnce(5) catch break;
713 if (srv.sessions.table[0] == null) { 713 if (srv.sessions.table[0] == null) {
714 reaped = true; 714 reaped = true;
715 break; 715 break;
src/server_test_deliver.zig
Old New
@@ -178,7 +178,7 @@ test "Server: a writable backlog is flushed by poll, not mistaken for input" {
178 var drain: [16 * 1024]u8 = undefined; 178 var drain: [16 * 1024]u8 = undefined;
179 _ = try std.posix.read(c.peer, &drain); 179 _ = try std.posix.read(c.peer, &drain);
180 180
181 _ = try srv.pumpOnce(50); 181 try srv.pumpOnce(50);
182 182
183 // The other half: the pump used the POLLOUT for what it was. 183 // The other half: the pump used the POLLOUT for what it was.
184 try std.testing.expect(srv.clients[0] != null); 184 try std.testing.expect(srv.clients[0] != null);
src/server_test_harness.zig
Old New
@@ -10,8 +10,7 @@ const Server = srv_mod.Server;
10 10
11 pub fn serverThread(srv: *Server, stop: *std.atomic.Value(bool)) void { 11 pub fn serverThread(srv: *Server, stop: *std.atomic.Value(bool)) void {
12 while (!stop.load(.acquire)) { 12 while (!stop.load(.acquire)) {
13 const code = srv.pumpOnce(50) catch break; 13 srv.pumpOnce(50) catch break;
14 if (code != null) break;
15 } 14 }
16 } 15 }
17 16
@@ -113,14 +112,11 @@ pub fn quicPump(
113 budget_ms: u64, 112 budget_ms: u64,
114 ctx: anytype, 113 ctx: anytype,
115 done: *const fn (@TypeOf(ctx)) bool, 114 done: *const fn (@TypeOf(ctx)) bool,
116 ) !?u8 { 115 ) !void {
117 var waited: u64 = 0; 116 var waited: u64 = 0;
118 var exit_code: ?u8 = null;
119 while (waited < budget_ms) { 117 while (waited < budget_ms) {
120 if (done(ctx)) return exit_code; 118 if (done(ctx)) return;
121 if (exit_code == null) { 119 try srv.pumpOnce(5);
122 exit_code = try srv.pumpOnce(5);
123 }
124 for (clients) |cl| { 120 for (clients) |cl| {
125 cl.drain(); 121 cl.drain();
126 var pfd = [_]std.posix.pollfd{.{ .fd = cl.fd, .events = std.posix.POLL.IN, .revents = 0 }}; 122 var pfd = [_]std.posix.pollfd{.{ .fd = cl.fd, .events = std.posix.POLL.IN, .revents = 0 }};
@@ -128,7 +124,6 @@ pub fn quicPump(
128 } 124 }
129 waited += 6; 125 waited += 6;
130 } 126 }
131 return exit_code;
132 } 127 }
133 128
134 /// Find a frame of `want` in a client's received bytes, returning its 129 /// Find a frame of `want` in a client's received bytes, returning its
@@ -167,7 +162,7 @@ pub fn awaitFrame(
167 ) !?proto.Frame { 162 ) !?proto.Frame {
168 var i: usize = 0; 163 var i: usize = 0;
169 while (i < iters) : (i += 1) { 164 while (i < iters) : (i += 1) {
170 _ = try srv.pumpOnce(5); 165 try srv.pumpOnce(5);
171 var pfd = [_]std.posix.pollfd{ 166 var pfd = [_]std.posix.pollfd{
172 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, 167 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
173 }; 168 };
@@ -191,7 +186,7 @@ pub fn awaitGridText(
191 ) !bool { 186 ) !bool {
192 const deadline = std.time.milliTimestamp() + budget_ms; 187 const deadline = std.time.milliTimestamp() + budget_ms;
193 while (std.time.milliTimestamp() < deadline) { 188 while (std.time.milliTimestamp() < deadline) {
194 _ = try srv.pumpOnce(5); 189 try srv.pumpOnce(5);
195 const grid = try srv.sessions.table[0].?.eng.dumpPlain(alloc); 190 const grid = try srv.sessions.table[0].?.eng.dumpPlain(alloc);
196 defer alloc.free(grid); 191 defer alloc.free(grid);
197 if (std.mem.indexOf(u8, grid, needle) != null) return true; 192 if (std.mem.indexOf(u8, grid, needle) != null) return true;
src/server_test_modes.zig
Old New
@@ -7,17 +7,14 @@ const Server = srv_mod.Server;
7 const awaitFrame = h.awaitFrame; 7 const awaitFrame = h.awaitFrame;
8 const connectedPair = h.connectedPair; 8 const connectedPair = h.connectedPair;
9 9
10 /// Returns the exit code if the child died — a caller asserting about frames
11 /// wants to hear that rather than spin.
12 fn pumpAndCollectModes( 10 fn pumpAndCollectModes(
13 alloc: std.mem.Allocator, 11 alloc: std.mem.Allocator,
14 srv: *Server, 12 srv: *Server,
15 fd: std.posix.fd_t, 13 fd: std.posix.fd_t,
16 out: *std.ArrayList(proto.PtyModeFlags), 14 out: *std.ArrayList(proto.PtyModeFlags),
17 ) !?u8 { 15 ) !void {
18 const code = try srv.pumpOnce(20); 16 try srv.pumpOnce(20);
19 try drainModes(alloc, fd, out); 17 try drainModes(alloc, fd, out);
20 return code;
21 } 18 }
22 19
23 /// No pump: for paths that answer synchronously, where pumping would blur what 20 /// No pump: for paths that answer synchronously, where pumping would blur what
@@ -84,7 +81,7 @@ test "Server: an attach arriving on an established connection is answered with t
84 // And again once a pump has been round, where the daemon already knows 81 // And again once a pump has been round, where the daemon already knows
85 // the mode and the fallback is not what is being exercised. A reattach 82 // the mode and the fallback is not what is being exercised. A reattach
86 // over a live connection is a real client's reconnect path. 83 // over a live connection is a real client's reconnect path.
87 _ = try srv.pumpOnce(20); 84 try srv.pumpOnce(20);
88 modes.clearRetainingCapacity(); 85 modes.clearRetainingCapacity();
89 srv.pushInbound(0, frame.items); 86 srv.pushInbound(0, frame.items);
90 try drainModes(alloc, c.peer, &modes); 87 try drainModes(alloc, c.peer, &modes);
@@ -141,10 +138,7 @@ test "Server: the pty's mode bits reach a client on attach, and again only when
141 // terminal is doing. 138 // terminal is doing.
142 var rounds: usize = 0; 139 var rounds: usize = 0;
143 while (rounds < 250 and modes.items.len == 0) : (rounds += 1) { 140 while (rounds < 250 and modes.items.len == 0) : (rounds += 1) {
144 try std.testing.expectEqual( 141 try pumpAndCollectModes(alloc, &srv, c.handle, &modes);
145 @as(?u8, null),
146 try pumpAndCollectModes(alloc, &srv, c.handle, &modes),
147 );
148 } 142 }
149 try std.testing.expectEqual(@as(usize, 1), modes.items.len); 143 try std.testing.expectEqual(@as(usize, 1), modes.items.len);
150 try std.testing.expect(modes.items[0].icanon); 144 try std.testing.expect(modes.items[0].icanon);
@@ -157,10 +151,7 @@ test "Server: the pty's mode bits reach a client on attach, and again only when
157 151
158 rounds = 0; 152 rounds = 0;
159 while (rounds < 500 and modes.items.len < 2) : (rounds += 1) { 153 while (rounds < 500 and modes.items.len < 2) : (rounds += 1) {
160 try std.testing.expectEqual( 154 try pumpAndCollectModes(alloc, &srv, c.handle, &modes);
161 @as(?u8, null),
162 try pumpAndCollectModes(alloc, &srv, c.handle, &modes),
163 );
164 } 155 }
165 try std.testing.expectEqual(@as(usize, 2), modes.items.len); 156 try std.testing.expectEqual(@as(usize, 2), modes.items.len);
166 try std.testing.expect(!modes.items[1].echo); 157 try std.testing.expect(!modes.items[1].echo);
@@ -180,10 +171,7 @@ test "Server: the pty's mode bits reach a client on attach, and again only when
180 171
181 rounds = 0; 172 rounds = 0;
182 while (rounds < 500 and modes.items.len < 3) : (rounds += 1) { 173 while (rounds < 500 and modes.items.len < 3) : (rounds += 1) {
183 try std.testing.expectEqual( 174 try pumpAndCollectModes(alloc, &srv, c.handle, &modes);
184 @as(?u8, null),
185 try pumpAndCollectModes(alloc, &srv, c.handle, &modes),
186 );
187 } 175 }
188 try std.testing.expectEqual(@as(usize, 3), modes.items.len); 176 try std.testing.expectEqual(@as(usize, 3), modes.items.len);
189 try std.testing.expect(!modes.items[2].icanon); 177 try std.testing.expect(!modes.items[2].icanon);
@@ -197,10 +185,7 @@ test "Server: the pty's mode bits reach a client on attach, and again only when
197 // every `make test` for evidence already in hand. 185 // every `make test` for evidence already in hand.
198 rounds = 0; 186 rounds = 0;
199 while (rounds < 25) : (rounds += 1) { 187 while (rounds < 25) : (rounds += 1) {
200 try std.testing.expectEqual( 188 try pumpAndCollectModes(alloc, &srv, c.handle, &modes);
201 @as(?u8, null),
202 try pumpAndCollectModes(alloc, &srv, c.handle, &modes),
203 );
204 } 189 }
205 try std.testing.expectEqual(@as(usize, 3), modes.items.len); 190 try std.testing.expectEqual(@as(usize, 3), modes.items.len);
206 } 191 }
@@ -432,7 +417,7 @@ test "Server: a session enabling bracketed paste tells its clients, and not agai
432 var i: usize = 0; 417 var i: usize = 0;
433 while (i < 400 and after < 60) : (i += 1) { 418 while (i < 400 and after < 60) : (i += 1) {
434 if (saw_content) after += 1; 419 if (saw_content) after += 1;
435 _ = try srv.pumpOnce(5); 420 try srv.pumpOnce(5);
436 var pfd = [_]std.posix.pollfd{ 421 var pfd = [_]std.posix.pollfd{
437 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 }, 422 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
438 }; 423 };
@@ -563,7 +548,7 @@ test "Server: a joiner that resizes the grid is still told the session's modes"
563 var b_modes: ?bool = null; 548 var b_modes: ?bool = null;
564 var i: usize = 0; 549 var i: usize = 0;
565 while (i < 400 and !(a_resnapshotted and b_modes != null)) : (i += 1) { 550 while (i < 400 and !(a_resnapshotted and b_modes != null)) : (i += 1) {
566 _ = try srv.pumpOnce(5); 551 try srv.pumpOnce(5);
567 var pfds = [_]std.posix.pollfd{ 552 var pfds = [_]std.posix.pollfd{
568 .{ .fd = a.handle, .events = std.posix.POLL.IN, .revents = 0 }, 553 .{ .fd = a.handle, .events = std.posix.POLL.IN, .revents = 0 },
569 .{ .fd = b.handle, .events = std.posix.POLL.IN, .revents = 0 }, 554 .{ .fd = b.handle, .events = std.posix.POLL.IN, .revents = 0 },
@@ -643,7 +628,7 @@ test "Server: a window title reaches clients on change, and only on change" {
643 var i: usize = 0; 628 var i: usize = 0;
644 while (i < 400 and after < 60) : (i += 1) { 629 while (i < 400 and after < 60) : (i += 1) {
645 if (saw_content) after += 1; 630 if (saw_content) after += 1;
646 _ = try srv.pumpOnce(5); 631 try srv.pumpOnce(5);
647 var pfd = [_]std.posix.pollfd{ 632 var pfd = [_]std.posix.pollfd{
648 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 }, 633 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
649 }; 634 };
@@ -719,7 +704,7 @@ test "Server: a session that never set a title has none sent for it" {
719 } 704 }
720 after += 1; 705 after += 1;
721 } 706 }
722 _ = try srv.pumpOnce(5); 707 try srv.pumpOnce(5);
723 // Sliced to what exists rather than padded with a placeholder. The 708 // Sliced to what exists rather than padded with a placeholder. The
724 // padding this replaces put A's fd in slot 1 until B joined and 709 // padding this replaces put A's fd in slot 1 until B joined and
725 // relied on a `continue` to skip it — and in a test whose verdict is 710 // relied on a `continue` to skip it — and in a test whose verdict is
@@ -797,7 +782,7 @@ test "Server: a joiner that resizes the grid is still told the session's title"
797 defer if (b_title) |t| alloc.free(t); 782 defer if (b_title) |t| alloc.free(t);
798 var i: usize = 0; 783 var i: usize = 0;
799 while (i < 400 and !(a_resnapshotted and b_title != null)) : (i += 1) { 784 while (i < 400 and !(a_resnapshotted and b_title != null)) : (i += 1) {
800 _ = try srv.pumpOnce(5); 785 try srv.pumpOnce(5);
801 var pfds = [_]std.posix.pollfd{ 786 var pfds = [_]std.posix.pollfd{
802 .{ .fd = a.handle, .events = std.posix.POLL.IN, .revents = 0 }, 787 .{ .fd = a.handle, .events = std.posix.POLL.IN, .revents = 0 },
803 .{ .fd = b.handle, .events = std.posix.POLL.IN, .revents = 0 }, 788 .{ .fd = b.handle, .events = std.posix.POLL.IN, .revents = 0 },
@@ -830,7 +815,7 @@ fn modesWithResync(
830 var modes: ?bool = null; 815 var modes: ?bool = null;
831 var i: usize = 0; 816 var i: usize = 0;
832 while (i < 400 and modes == null) : (i += 1) { 817 while (i < 400 and modes == null) : (i += 1) {
833 _ = try srv.pumpOnce(5); 818 try srv.pumpOnce(5);
834 var pfd = [_]std.posix.pollfd{ 819 var pfd = [_]std.posix.pollfd{
835 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, 820 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
836 }; 821 };
src/server_test_quic.zig
Old New
@@ -115,7 +115,7 @@ test "Server: output reaches a silent QUIC client without waiting for it to spea
115 try attachOver(&cl, &out, alloc); 115 try attachOver(&cl, &out, alloc);
116 116
117 var only = [_]*quic_server.TestClient{&cl}; 117 var only = [_]*quic_server.TestClient{&cl};
118 _ = try quicPump(&srv, &only, 8000, &cl, struct { 118 try quicPump(&srv, &only, 8000, &cl, struct {
119 fn f(t: *quic_server.TestClient) bool { 119 fn f(t: *quic_server.TestClient) bool {
120 return findFrame(t.recv_buf[0..t.recv_len], .snapshot) != null; 120 return findFrame(t.recv_buf[0..t.recv_len], .snapshot) != null;
121 } 121 }
@@ -136,7 +136,7 @@ test "Server: output reaches a silent QUIC client without waiting for it to spea
136 var settle: usize = 0; 136 var settle: usize = 0;
137 while (settle < 400) : (settle += 1) { 137 while (settle < 400) : (settle += 1) {
138 if (q.l.timeoutMs(1000) > 100) break; 138 if (q.l.timeoutMs(1000) > 100) break;
139 _ = try srv.pumpOnce(5); 139 try srv.pumpOnce(5);
140 cl.drain(); 140 cl.drain();
141 var pfd = [_]std.posix.pollfd{ 141 var pfd = [_]std.posix.pollfd{
142 .{ .fd = cl.fd, .events = std.posix.POLL.IN, .revents = 0 }, 142 .{ .fd = cl.fd, .events = std.posix.POLL.IN, .revents = 0 },
@@ -163,7 +163,7 @@ test "Server: output reaches a silent QUIC client without waiting for it to spea
163 // 163 //
164 // One pump answers the only question that isolates it: the frame was 164 // One pump answers the only question that isolates it: the frame was
165 // queued before this call, so did THIS call put it on the wire? 165 // queued before this call, so did THIS call put it on the wire?
166 _ = try srv.pumpOnce(5); 166 try srv.pumpOnce(5);
167 167
168 // Reading is not transmitting: the client takes whatever already 168 // Reading is not transmitting: the client takes whatever already
169 // arrived, and never gives the server an inbound packet to react to. 169 // arrived, and never gives the server an inbound packet to react to.
@@ -205,7 +205,7 @@ test "Server: a QUIC client still receives the shell's exit status" {
205 // Attached and carrying state: the frame path works before we start 205 // Attached and carrying state: the frame path works before we start
206 // asking about the harder one. 206 // asking about the harder one.
207 var only = [_]*quic_server.TestClient{&cl}; 207 var only = [_]*quic_server.TestClient{&cl};
208 _ = try quicPump(&srv, &only, 8000, &cl, struct { 208 try quicPump(&srv, &only, 8000, &cl, struct {
209 fn f(t: *quic_server.TestClient) bool { 209 fn f(t: *quic_server.TestClient) bool {
210 return findFrame(t.recv_buf[0..t.recv_len], .snapshot) != null; 210 return findFrame(t.recv_buf[0..t.recv_len], .snapshot) != null;
211 } 211 }
@@ -219,12 +219,11 @@ test "Server: a QUIC client still receives the shell's exit status" {
219 cl.out = out.items; 219 cl.out = out.items;
220 cl.drain(); 220 cl.drain();
221 221
222 const code = try quicPump(&srv, &only, 15000, &cl, struct { 222 try quicPump(&srv, &only, 15000, &cl, struct {
223 fn f(t: *quic_server.TestClient) bool { 223 fn f(t: *quic_server.TestClient) bool {
224 return findFrame(t.recv_buf[0..t.recv_len], .exit_status) != null; 224 return findFrame(t.recv_buf[0..t.recv_len], .exit_status) != null;
225 } 225 }
226 }.f); 226 }.f);
227 _ = code;
228 227
229 const status = findFrame(cl.recv_buf[0..cl.recv_len], .exit_status); 228 const status = findFrame(cl.recv_buf[0..cl.recv_len], .exit_status);
230 try std.testing.expect(status != null); 229 try std.testing.expect(status != null);
@@ -263,7 +262,7 @@ test "Server: one QUIC client leaving does not disturb the other" {
263 try attachOver(&b, &bbuf, alloc); 262 try attachOver(&b, &bbuf, alloc);
264 263
265 var both = [_]*quic_server.TestClient{ &a, &b }; 264 var both = [_]*quic_server.TestClient{ &a, &b };
266 _ = try quicPump(&srv, &both, 10000, &b, struct { 265 try quicPump(&srv, &both, 10000, &b, struct {
267 fn f(t: *quic_server.TestClient) bool { 266 fn f(t: *quic_server.TestClient) bool {
268 return findFrame(t.recv_buf[0..t.recv_len], .snapshot) != null; 267 return findFrame(t.recv_buf[0..t.recv_len], .snapshot) != null;
269 } 268 }
@@ -277,7 +276,7 @@ test "Server: one QUIC client leaving does not disturb the other" {
277 try proto.appendFrame(&abuf, alloc, .detach, ""); 276 try proto.appendFrame(&abuf, alloc, .detach, "");
278 a.out = abuf.items; 277 a.out = abuf.items;
279 a.drain(); 278 a.drain();
280 _ = try quicPump(&srv, &both, 4000, &srv, struct { 279 try quicPump(&srv, &both, 4000, &srv, struct {
281 fn f(s: *Server) bool { 280 fn f(s: *Server) bool {
282 var n: usize = 0; 281 var n: usize = 0;
283 for (s.clients) |slot| { 282 for (s.clients) |slot| {
@@ -294,7 +293,7 @@ test "Server: one QUIC client leaving does not disturb the other" {
294 b.drain(); 293 b.drain();
295 var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); 294 var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
296 defer replica.deinit(); 295 defer replica.deinit();
297 _ = try quicPump(&srv, &both, 15000, &b, struct { 296 try quicPump(&srv, &both, 15000, &b, struct {
298 fn f(t: *quic_server.TestClient) bool { 297 fn f(t: *quic_server.TestClient) bool {
299 return t.recv_len > 0 and findFrame(t.recv_buf[0..t.recv_len], .delta) != null; 298 return t.recv_len > 0 and findFrame(t.recv_buf[0..t.recv_len], .delta) != null;
300 } 299 }
@@ -325,7 +324,7 @@ test "Server: a QUIC client that stops reading is dropped by the cap, not tolera
325 try attachOver(&cl, &bbuf, alloc); 324 try attachOver(&cl, &bbuf, alloc);
326 325
327 var only = [_]*quic_server.TestClient{&cl}; 326 var only = [_]*quic_server.TestClient{&cl};
328 _ = try quicPump(&srv, &only, 8000, &cl, struct { 327 try quicPump(&srv, &only, 8000, &cl, struct {
329 fn f(t: *quic_server.TestClient) bool { 328 fn f(t: *quic_server.TestClient) bool {
330 return t.handshake_done and t.echoed > 0; 329 return t.handshake_done and t.echoed > 0;
331 } 330 }
@@ -349,7 +348,7 @@ test "Server: a QUIC client that stops reading is dropped by the cap, not tolera
349 _ = srv.queueFrame(0, .snapshot, chunk); 348 _ = srv.queueFrame(0, .snapshot, chunk);
350 // The daemon's own machinery, not a hand-rolled loop: expiry, 349 // The daemon's own machinery, not a hand-rolled loop: expiry,
351 // egress and the flush that follows an ack all live in pumpOnce. 350 // egress and the flush that follows an ack all live in pumpOnce.
352 _ = srv.pumpOnce(1) catch null; 351 srv.pumpOnce(1) catch {};
353 } 352 }
354 353
355 try std.testing.expect(srv.clients[0] == null); 354 try std.testing.expect(srv.clients[0] == null);
@@ -381,7 +380,7 @@ test "Server: drainPending waits for a QUIC client's acks, not just its queue" {
381 try attachOver(&cl, &bbuf, alloc); 380 try attachOver(&cl, &bbuf, alloc);
382 381
383 var only = [_]*quic_server.TestClient{&cl}; 382 var only = [_]*quic_server.TestClient{&cl};
384 _ = try quicPump(&srv, &only, 8000, &cl, struct { 383 try quicPump(&srv, &only, 8000, &cl, struct {
385 fn f(t: *quic_server.TestClient) bool { 384 fn f(t: *quic_server.TestClient) bool {
386 return t.handshake_done and t.echoed > 0; 385 return t.handshake_done and t.echoed > 0;
387 } 386 }
src/server_test_session.zig
Old New
@@ -376,7 +376,7 @@ test "Server: the stats buffer holds the widest reply its format can print" {
376 } 376 }
377 } 377 }
378 378
379 test "Server: stop_req from a bare connection requests shutdown; run returns 130" { 379 test "Server: stop_req from a bare connection requests shutdown; run returns 0" {
380 const alloc = std.testing.allocator; 380 const alloc = std.testing.allocator;
381 381
382 var tmp = try TmpDir.make(); 382 var tmp = try TmpDir.make();
@@ -402,7 +402,7 @@ test "Server: stop_req from a bare connection requests shutdown; run returns 130
402 // suite: 100 iterations x 50ms is the deadline, the flag is the exit. 402 // suite: 100 iterations x 50ms is the deadline, the flag is the exit.
403 var i: usize = 0; 403 var i: usize = 0;
404 while (i < 100 and !shutdown_flag.load(.acquire)) : (i += 1) { 404 while (i < 100 and !shutdown_flag.load(.acquire)) : (i += 1) {
405 _ = try srv.pumpOnce(50); 405 try srv.pumpOnce(50);
406 } 406 }
407 try std.testing.expect(shutdown_flag.load(.acquire)); 407 try std.testing.expect(shutdown_flag.load(.acquire));
408 408
@@ -411,7 +411,10 @@ test "Server: stop_req from a bare connection requests shutdown; run returns 130
411 // business, not run()'s. It is also the one unbounded call here, and it 411 // business, not run()'s. It is also the one unbounded call here, and it
412 // is safe only because the expect above guarantees the flag: an arm 412 // is safe only because the expect above guarantees the flag: an arm
413 // mutation fails there and never reaches this line. 413 // mutation fails there and never reaches this line.
414 try std.testing.expectEqual(@as(u8, 130), try srv.run()); 414 //
415 // 0: a daemon that served and was asked to stop did its job. A nonzero
416 // exit here is what a supervisor reads as a crash-loop.
417 try std.testing.expectEqual(@as(u8, 0), try srv.run());
415 } 418 }
416 419
417 test "Server: stop_req from an attached client is honored too" { 420 test "Server: stop_req from an attached client is honored too" {
@@ -441,7 +444,7 @@ test "Server: stop_req from an attached client is honored too" {
441 444
442 var i: usize = 0; 445 var i: usize = 0;
443 while (i < 100 and !shutdown_flag.load(.acquire)) : (i += 1) { 446 while (i < 100 and !shutdown_flag.load(.acquire)) : (i += 1) {
444 _ = try srv.pumpOnce(50); 447 try srv.pumpOnce(50);
445 } 448 }
446 try std.testing.expect(shutdown_flag.load(.acquire)); 449 try std.testing.expect(shutdown_flag.load(.acquire));
447 } 450 }
@@ -609,7 +612,7 @@ fn pumpUntilReplicaSees(
609 ) !bool { 612 ) !bool {
610 var i: usize = 0; 613 var i: usize = 0;
611 while (i < iters) : (i += 1) { 614 while (i < iters) : (i += 1) {
612 _ = try srv.pumpOnce(5); 615 try srv.pumpOnce(5);
613 var pfd = [_]std.posix.pollfd{ 616 var pfd = [_]std.posix.pollfd{
614 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }, 617 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
615 }; 618 };
@@ -822,7 +825,7 @@ test "Server: an attach past max_sessions is refused with exit_status, sessions
822 // The closes above are only visible to the daemon once it polls, and a 825 // The closes above are only visible to the daemon once it polls, and a
823 // client slot it still believes is live would refuse the probe below 826 // client slot it still believes is live would refuse the probe below
824 // before the session table ever got asked. 827 // before the session table ever got asked.
825 for (0..8) |_| _ = try srv.pumpOnce(1); 828 for (0..8) |_| try srv.pumpOnce(1);
826 829
827 // One name past the table gets the same honest no a full client table 830 // One name past the table gets the same honest no a full client table
828 // gives. 831 // gives.
@@ -1012,15 +1015,13 @@ test "Server: one session's shell exiting drops only its clients; the daemon car
1012 try pumpUntilReplicaSees(alloc, &srv, cb.handle, rep_b, "echo:pre", 400), 1015 try pumpUntilReplicaSees(alloc, &srv, cb.handle, rep_b, "echo:pre", 400),
1013 ); 1016 );
1014 1017
1015 // Kill a's shell, and pump until its client hears so. Every pump must 1018 // Kill a's shell and pump until its client hears so. b is what makes
1016 // return null: one session dying is not the daemon's end while others 1019 // this a comparison: a's death must reach a's client and nobody else's.
1017 // live — this loop IS the regression the task exists to fix, since the
1018 // old exit arm returned a's code right here.
1019 try proto.writeFrame(ca.handle, .input, "die 0\n"); 1020 try proto.writeFrame(ca.handle, .input, "die 0\n");
1020 var status: ?u8 = null; 1021 var status: ?u8 = null;
1021 var rounds: usize = 0; 1022 var rounds: usize = 0;
1022 while (rounds < 500 and status == null) : (rounds += 1) { 1023 while (rounds < 500 and status == null) : (rounds += 1) {
1023 try std.testing.expectEqual(@as(?u8, null), try srv.pumpOnce(5)); 1024 try srv.pumpOnce(5);
1024 var pfd = [_]std.posix.pollfd{ 1025 var pfd = [_]std.posix.pollfd{
1025 .{ .fd = ca.handle, .events = std.posix.POLL.IN, .revents = 0 }, 1026 .{ .fd = ca.handle, .events = std.posix.POLL.IN, .revents = 0 },
1026 }; 1027 };
@@ -1060,38 +1061,117 @@ test "Server: one session's shell exiting drops only its clients; the daemon car
1060 ); 1061 );
1061 } 1062 }
1062 1063
1063 test "Server: the last session's exit code is the daemon's" { 1064 /// `run` on a thread; the code it answers is a fact only `run` holds.
1065 fn runThread(srv: *Server, out: *?u8) void {
1066 out.* = srv.run() catch |err| {
1067 std.debug.print("run() failed: {t}\n", .{err});
1068 return;
1069 };
1070 }
1071
1072 /// Not `awaitFrame`: that one pumps the server, and here a thread does.
1073 fn awaitFrameThreaded(
1074 alloc: std.mem.Allocator,
1075 fd: std.posix.fd_t,
1076 want: proto.MsgType,
1077 timeout_ms: u64,
1078 ) !?proto.Frame {
1079 var left = timeout_ms;
1080 while (left > 0) {
1081 var pfd = [_]std.posix.pollfd{
1082 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
1083 };
1084 const ready = try std.posix.poll(&pfd, 50);
1085 left -|= 50;
1086 if (ready == 0) continue;
1087 const frame = (try proto.readFrame(alloc, fd)) orelse return null;
1088 if (frame.type == want) return frame;
1089 frame.deinit(alloc);
1090 }
1091 return null;
1092 }
1093
1094 /// Ask the daemon for its stats until `want` appears in the text. The
1095 /// counter is the daemon's own account of its table — what `muxd stats`
1096 /// shows an operator looking at a box with nothing running on it.
1097 fn waitStats(alloc: std.mem.Allocator, fd: std.posix.fd_t, want: []const u8) !void {
1098 var tries: usize = 0;
1099 while (tries < 40) : (tries += 1) {
1100 try proto.writeFrame(fd, .stats_req, "");
1101 const frame = (try awaitFrameThreaded(alloc, fd, .stats_reply, 200)) orelse continue;
1102 defer frame.deinit(alloc);
1103 if (std.mem.indexOf(u8, frame.payload, want) != null) return;
1104 }
1105 std.debug.print("stats never said {s}\n", .{want});
1106 return error.StatsNeverSaid;
1107 }
1108
1109 /// The socket half of the claim: every wait here runs while `run` pumps on
1110 /// its own thread, so a daemon that RETURNED from `run` answers none of it
1111 /// and each one times out.
1112 fn probeEmptiedDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !void {
1113 const obs = try std.net.connectUnixSocket(sock_path);
1114 defer obs.close();
1115
1116 // Attach the default session, then hang up its shell — the daemon's
1117 // only one, so the table empties behind it.
1118 const c1 = try std.net.connectUnixSocket(sock_path);
1119 defer c1.close();
1120 try attachNamed(c1.handle, 80, 24, proto.default_session);
1121 const snap = (try awaitFrameThreaded(alloc, c1.handle, .snapshot, 4000)) orelse
1122 return error.NoFirstSnapshot;
1123 snap.deinit(alloc);
1124 try proto.writeFrame(c1.handle, .input, "die 0\n");
1125
1126 try waitStats(alloc, obs.handle, "sessions=0");
1127
1128 // Still serving with nothing to serve: a birth on the emptied daemon
1129 // takes the default name back, which is the attach `mux --sock PATH`
1130 // sends.
1131 const c2 = try std.net.connectUnixSocket(sock_path);
1132 defer c2.close();
1133 try attachNamed(c2.handle, 80, 24, proto.default_session);
1134 const reborn = (try awaitFrameThreaded(alloc, c2.handle, .snapshot, 4000)) orelse
1135 return error.EmptyDaemonRefusedABirth;
1136 reborn.deinit(alloc);
1137 try waitStats(alloc, obs.handle, "sessions=1");
1138 }
1139
1140 test "Server: a daemon outlives its last session and ends only on stop" {
1064 const alloc = std.testing.allocator; 1141 const alloc = std.testing.allocator;
1065 1142
1066 var tmp = try TmpDir.make(); 1143 var tmp = try TmpDir.make();
1067 defer tmp.cleanup(); 1144 defer tmp.cleanup();
1068 const dir_path = tmp.path(); 1145 const dir_path = tmp.path();
1069 const sock_path = try std.fmt.allocPrint(alloc, "{s}/lastcode.sock", .{dir_path}); 1146 const sock_path = try std.fmt.allocPrint(alloc, "{s}/livesempty.sock", .{dir_path});
1070 defer alloc.free(sock_path); 1147 defer alloc.free(sock_path);
1071 1148
1072 // A shell that exits 7 on its own: the default session is the only one 1149 try writeMortalScript(&tmp);
1073 // this daemon ever holds, so its death is the last death. 1150 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/mortal.sh", .{dir_path}, 0);
1074 try tmp.dir.writeFile(.{
1075 .sub_path = "exit7.sh",
1076 .data =
1077 \\#!/bin/sh
1078 \\exit 7
1079 \\
1080 ,
1081 .flags = .{ .mode = 0o755 },
1082 });
1083 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/exit7.sh", .{dir_path}, 0);
1084 defer alloc.free(script); 1151 defer alloc.free(script);
1085 1152
1153 // The flag is global by necessity (a signal handler shares it); reset
1154 // so this test neither inherits a stale request nor leaves one behind.
1155 shutdown_flag.store(false, .release);
1156 defer shutdown_flag.store(false, .release);
1157
1086 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script }); 1158 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
1087 defer srv.deinit(); 1159 defer srv.deinit();
1088 1160
1089 var code: ?u8 = null; 1161 var code: ?u8 = null;
1090 var rounds: usize = 0; 1162 const th = try std.Thread.spawn(.{}, runThread, .{ &srv, &code });
1091 while (rounds < 500 and code == null) : (rounds += 1) { 1163 // Captured, not propagated: nothing may return between the spawn and
1092 code = try srv.pumpOnce(5); 1164 // the join, because `deinit` is a demolition list and must never run
1093 } 1165 // under a live pump thread.
1094 try std.testing.expectEqual(@as(?u8, 7), code); 1166 const probed = probeEmptiedDaemon(alloc, sock_path);
1167 shutdown_flag.store(true, .release);
1168 th.join();
1169 try probed;
1170
1171 // Not the code of any shell: the one this daemon was born with was hung
1172 // up with `die 0` two stats replies ago, and the one born after it is
1173 // still alive. This is the shutdown's own code.
1174 try std.testing.expectEqual(@as(?u8, 0), code);
1095 } 1175 }
1096 1176
1097 test "Server: a dead name re-attaches as a fresh session with a new epoch" { 1177 test "Server: a dead name re-attaches as a fresh session with a new epoch" {
@@ -1122,12 +1202,11 @@ test "Server: a dead name re-attaches as a fresh session with a new epoch" {
1122 const p1 = try proto.readSnapshotPrefix(f1.payload); 1202 const p1 = try proto.readSnapshotPrefix(f1.payload);
1123 try std.testing.expect(p1.epoch != 0); 1203 try std.testing.expect(p1.epoch != 0);
1124 1204
1125 // Kill it, and pump until the name is free. Null throughout: the 1205 // Kill it, and pump until the name is free.
1126 // default session still lives.
1127 try proto.writeFrame(c1.handle, .input, "die 0\n"); 1206 try proto.writeFrame(c1.handle, .input, "die 0\n");
1128 var rounds: usize = 0; 1207 var rounds: usize = 0;
1129 while (rounds < 500 and srv.sessions.find("a") != null) : (rounds += 1) { 1208 while (rounds < 500 and srv.sessions.find("a") != null) : (rounds += 1) {
1130 try std.testing.expectEqual(@as(?u8, null), try srv.pumpOnce(5)); 1209 try srv.pumpOnce(5);
1131 } 1210 }
1132 try std.testing.expect(srv.sessions.find("a") == null); 1211 try std.testing.expect(srv.sessions.find("a") == null);
1133 1212
@@ -1147,7 +1226,7 @@ test "Server: a dead name re-attaches as a fresh session with a new epoch" {
1147 var second: ?proto.SnapshotPrefix = null; 1226 var second: ?proto.SnapshotPrefix = null;
1148 rounds = 0; 1227 rounds = 0;
1149 while (rounds < 400 and second == null) : (rounds += 1) { 1228 while (rounds < 400 and second == null) : (rounds += 1) {
1150 try std.testing.expectEqual(@as(?u8, null), try srv.pumpOnce(5)); 1229 try srv.pumpOnce(5);
1151 var pfd = [_]std.posix.pollfd{ 1230 var pfd = [_]std.posix.pollfd{
1152 .{ .fd = c2.handle, .events = std.posix.POLL.IN, .revents = 0 }, 1231 .{ .fd = c2.handle, .events = std.posix.POLL.IN, .revents = 0 },
1153 }; 1232 };
@@ -1298,7 +1377,7 @@ test "Server: an observer's status_req names a session by tail" {
1298 var saw_eof = false; 1377 var saw_eof = false;
1299 var iters: usize = 0; 1378 var iters: usize = 0;
1300 while (iters < 400 and !saw_eof) : (iters += 1) { 1379 while (iters < 400 and !saw_eof) : (iters += 1) {
1301 _ = try srv.pumpOnce(5); 1380 try srv.pumpOnce(5);
1302 var pfd = [_]std.posix.pollfd{ 1381 var pfd = [_]std.posix.pollfd{
1303 .{ .fd = obs.handle, .events = std.posix.POLL.IN, .revents = 0 }, 1382 .{ .fd = obs.handle, .events = std.posix.POLL.IN, .revents = 0 },
1304 }; 1383 };
@@ -1569,7 +1648,7 @@ test "Server: end_req with another client attached is refused with the count; fo
1569 x2.deinit(alloc); 1648 x2.deinit(alloc);
1570 var waited: u32 = 0; 1649 var waited: u32 = 0;
1571 while (alive(pid_a) and waited < 3000) : (waited += 50) { 1650 while (alive(pid_a) and waited < 3000) : (waited += 50) {
1572 _ = try srv.pumpOnce(20); 1651 try srv.pumpOnce(20);
1573 std.Thread.sleep(30 * std.time.ns_per_ms); 1652 std.Thread.sleep(30 * std.time.ns_per_ms);
1574 } 1653 }
1575 try std.testing.expect(!alive(pid_a)); 1654 try std.testing.expect(!alive(pid_a));
@@ -1600,7 +1679,7 @@ test "Server: end_req alone on a session ends it at once, and an unknown name is
1600 try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted); 1679 try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted);
1601 var waited: u32 = 0; 1680 var waited: u32 = 0;
1602 while (alive(pid) and waited < 3000) : (waited += 50) { 1681 while (alive(pid) and waited < 3000) : (waited += 50) {
1603 _ = try srv.pumpOnce(20); 1682 try srv.pumpOnce(20);
1604 std.Thread.sleep(30 * std.time.ns_per_ms); 1683 std.Thread.sleep(30 * std.time.ns_per_ms);
1605 } 1684 }
1606 try std.testing.expect(!alive(pid)); 1685 try std.testing.expect(!alive(pid));
@@ -1657,7 +1736,7 @@ fn awaitGridIn(
1657 ) !bool { 1736 ) !bool {
1658 const deadline = std.time.milliTimestamp() + budget_ms; 1737 const deadline = std.time.milliTimestamp() + budget_ms;
1659 while (std.time.milliTimestamp() < deadline) { 1738 while (std.time.milliTimestamp() < deadline) {
1660 _ = try srv.pumpOnce(5); 1739 try srv.pumpOnce(5);
1661 const si = srv.sessions.find(proto.wireName(name)) orelse return false; 1740 const si = srv.sessions.find(proto.wireName(name)) orelse return false;
1662 const grid = try srv.ses(si).eng.dumpPlain(alloc); 1741 const grid = try srv.ses(si).eng.dumpPlain(alloc);
1663 defer alloc.free(grid); 1742 defer alloc.free(grid);
@@ -1698,7 +1777,7 @@ test "Server: an accepted end kills a shell that ignores TERM and HUP" {
1698 1777
1699 var waited: u32 = 0; 1778 var waited: u32 = 0;
1700 while (alive(pid) and waited < 3000) : (waited += 50) { 1779 while (alive(pid) and waited < 3000) : (waited += 50) {
1701 _ = try srv.pumpOnce(20); 1780 try srv.pumpOnce(20);
1702 std.Thread.sleep(30 * std.time.ns_per_ms); 1781 std.Thread.sleep(30 * std.time.ns_per_ms);
1703 } 1782 }
1704 try std.testing.expect(!alive(pid)); 1783 try std.testing.expect(!alive(pid));
@@ -1768,7 +1847,7 @@ test "Server: a repeated end_req does not push the SIGKILL deadline out" {
1768 var waited: u32 = 0; 1847 var waited: u32 = 0;
1769 while (alive(pid) and waited < 3000) : (waited += 80) { 1848 while (alive(pid) and waited < 3000) : (waited += 80) {
1770 proto.writeFrame(obs.handle, .end_req, proto.encodeEndReq(&rq, true, "nag")) catch {}; 1849 proto.writeFrame(obs.handle, .end_req, proto.encodeEndReq(&rq, true, "nag")) catch {};
1771 _ = try srv.pumpOnce(20); 1850 try srv.pumpOnce(20);
1772 std.Thread.sleep(60 * std.time.ns_per_ms); 1851 std.Thread.sleep(60 * std.time.ns_per_ms);
1773 } 1852 }
1774 try std.testing.expect(!alive(pid)); 1853 try std.testing.expect(!alive(pid));
src/server_test_upgrade.zig
Old New
@@ -445,3 +445,98 @@ test "Server: an upgrade asked for during a session's hangup is refused, not att
445 try std.testing.expectEqual(@as(u8, 1), reply.payload[0]); 445 try std.testing.expectEqual(@as(u8, 1), reply.payload[0]);
446 try std.testing.expect(std.mem.indexOf(u8, reply.payload[1..], "session ending") != null); 446 try std.testing.expect(std.mem.indexOf(u8, reply.payload[1..], "session ending") != null);
447 } 447 }
448
449 test "Server: an EMPTY daemon upgrades — the manifest names no session and the new image serves" {
450 const alloc = std.testing.allocator;
451
452 var tmp = try TmpDir.make();
453 defer tmp.cleanup();
454 const dir_path = tmp.path();
455 const sock_path = try std.fmt.allocPrint(alloc, "{s}/upempty.sock", .{dir_path});
456 defer alloc.free(sock_path);
457
458 // A shell that leaves the moment it is forked: the daemon's only
459 // session, so the table is empty a pump later. That state was
460 // unreachable while a daemon left with its last session; it is an
461 // ordinary one now, and the exec has to cross it.
462 try tmp.dir.writeFile(.{
463 .sub_path = "exit0.sh",
464 .data =
465 \\#!/bin/sh
466 \\exit 0
467 \\
468 ,
469 .flags = .{ .mode = 0o755 },
470 });
471 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/exit0.sh", .{dir_path}, 0);
472 defer alloc.free(script);
473
474 var srv = try Server.init(alloc, .{
475 .sock_path = sock_path,
476 .shell = script,
477 .version = "0.0.1-1",
478 });
479
480 var rounds: usize = 0;
481 while (rounds < 500 and srv.sessions.live() != 0) : (rounds += 1) try srv.pumpOnce(5);
482 try std.testing.expectEqual(@as(usize, 0), srv.sessions.live());
483
484 // A candidate that passes every check, run against a daemon holding
485 // nothing: emptiness must not be mistaken for the one state that DOES
486 // refuse an upgrade, a session mid-hangup.
487 try tmp.dir.writeFile(.{
488 .sub_path = "fakemuxd.sh",
489 .data =
490 \\#!/bin/sh
491 \\case "$1" in
492 \\ --version) printf 'muxd 9.9.9\n' ;;
493 \\esac
494 \\exit 0
495 \\
496 ,
497 .flags = .{ .mode = 0o755 },
498 });
499 const cand = try std.fmt.allocPrint(alloc, "{s}/fakemuxd.sh", .{dir_path});
500 defer alloc.free(cand);
501 if (srv.validateUpgrade(.{
502 .allow_same_version = false,
503 .version = "9.9.9",
504 .path = cand,
505 }, "0.0.1-1")) |reason| {
506 defer alloc.free(reason);
507 std.debug.print("an empty daemon was refused an upgrade: {s}\n", .{reason});
508 return error.EmptyDaemonRefusedUpgrade;
509 }
510
511 const memfd = try std.posix.memfd_create("mux-empty-upgrade", 0);
512 defer std.posix.close(memfd);
513 try srv.writeManifestTo(memfd, "0.0.1-1");
514
515 // The first Server's MEMORY only, never deinit: deinit unlinks the
516 // socket and deleteTrees the dirs, and the adopting Server is about to
517 // inherit all of them. With no sessions there is nothing else to free.
518 if (srv.agents.dir) |d| alloc.free(d);
519 srv.shellint_arena.deinit();
520
521 var file = std.fs.File{ .handle = memfd };
522 try file.seekTo(0);
523 const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024);
524 defer alloc.free(buf);
525 var parsed = try upgrade.parseManifest(alloc, buf);
526 defer parsed.deinit();
527 try std.testing.expectEqual(@as(usize, 0), parsed.sessions.len);
528
529 var srv2 = try Server.initFromManifest(alloc, &parsed, "0.0.1-2");
530 defer srv2.deinit();
531 try std.testing.expectEqual(@as(usize, 0), srv2.sessions.live());
532
533 // Serving, on the listener fd the old image bound: an empty daemon that
534 // came through an exec is still the box a client can be born on.
535 const obs = try std.net.connectUnixSocket(sock_path);
536 defer obs.close();
537 try proto.writeFrame(obs.handle, .stats_req, "");
538 const reply = (try awaitFrame(alloc, &srv2, obs.handle, .stats_reply, 400)) orelse
539 return error.NoStatsReply;
540 defer reply.deinit(alloc);
541 try std.testing.expect(std.mem.indexOf(u8, reply.payload, "sessions=0") != null);
542 }
test/agent.sh
Old New
@@ -848,9 +848,9 @@ scen_session_exit() {
848 return 0 848 return 0
849 } 849 }
850 run_scenario "quic: a session that exits 5 reports 5, not a lost connection" scen_session_exit 850 run_scenario "quic: a session that exits 5 reports 5, not a lost connection" scen_session_exit
851 # The tear daemon's session is gone with it; drop the pid so cleanup does not 851 # The pid is KEPT. The scenario ended this daemon's only session, and a
852 # chase one, and let the socket backstop cover the rest. 852 # daemon lives until `muxd stop` — so it is still running, and the sweep
853 D_TEAR="" 853 # below has to wait for its leak verdict like any other daemon's.
854 854
855 # --- 9: a destination that swallows still honours --timeout ---------------- 855 # --- 9: a destination that swallows still honours --timeout ----------------
856 # Not a refusal: a never-listening port answers with ICMP and muxa fails 856 # Not a refusal: a never-listening port answers with ICMP and muxa fails
@@ -937,9 +937,10 @@ fi
937 # Every daemon this run started wrote its verdict into a log of its own (see 937 # Every daemon this run started wrote its verdict into a log of its own (see
938 # start_daemon), so this reads ALL of them rather than the most recent one's. 938 # start_daemon), so this reads ALL of them rather than the most recent one's.
939 # leakcheck already gated the three scenarios that stop their own daemon; what 939 # leakcheck already gated the three scenarios that stop their own daemon; what
940 # lands here is everything else — the TUI daemon its own session ended, the 940 # lands here is everything else — the TUI daemon whose session ended under
941 # QUIC daemon scenario 8 destroyed, and any daemon a FAILING scenario left 941 # it, the QUIC daemon scenario 8 emptied, and any daemon a FAILING scenario
942 # behind, which is exactly the case that used to go unswept. 942 # left behind, which is exactly the case that used to go unswept. Emptying a
943 # daemon does not remove it from this list: nothing but `muxd stop` ends one.
943 # 944 #
944 # After the count pin on purpose: a leak is not a scenario, and folding it 945 # After the count pin on purpose: a leak is not a scenario, and folding it
945 # into PASSES+FAILS+SKIPS would make that pin's number stop meaning "nine 946 # into PASSES+FAILS+SKIPS would make that pin's number stop meaning "nine
test/e2e_05_session.sh
Old New
@@ -139,7 +139,7 @@ if grep -q 'm18b-pin' "$OUT.m18mcap"; then
139 fi 139 fi
140 140
141 # Lifetime: a session ends when ITS shell exits, and the daemon outlives 141 # Lifetime: a session ends when ITS shell exits, and the daemon outlives
142 # every death but the last (decision 8). This client attaches to a session 142 # every one of those deaths (decision 8). This client attaches to a session
143 # that ALREADY EXISTS — attach-or-create's join arm, the other half of the 143 # that ALREADY EXISTS — attach-or-create's join arm, the other half of the
144 # two creations above — and types the exit that ends it. 144 # two creations above — and types the exit that ends it.
145 pipe_mux "$OUT.m18ax" "$OUT.m18ax.err" timeout 40 "$MUX" --sock "$SOCK21" --session a 145 pipe_mux "$OUT.m18ax" "$OUT.m18ax.err" timeout 40 "$MUX" --sock "$SOCK21" --session a
@@ -159,9 +159,43 @@ dump_session "$SOCK21" b | grep -q "m18b-pin" || {
159 echo "e2e FAIL: M18: session b lost its grid when session a died:" 159 echo "e2e FAIL: M18: session b lost its grid when session a died:"
160 dump_session "$SOCK21" b; exit 1; } 160 dump_session "$SOCK21" b; exit 1; }
161 161
162 # ...and it outlives the LAST death too. `x` ends a session, never a box: b
163 # and the default are exited in turn, and what is left is a daemon with
164 # nothing on it — the state the host picker calls `no sessions`, and the
165 # reason an emptied host is still there to be picked.
166 pipe_mux "$OUT.m18bx" "$OUT.m18bx.err" timeout 40 "$MUX" --sock "$SOCK21" --session b
167 pipe_send 'exit\n'
168 pipe_waitexit "M18: the client of session b"
169 pipe_mux "$OUT.m18dx" "$OUT.m18dx.err" timeout 40 "$MUX" --sock "$SOCK21"
170 pipe_send 'exit\n'
171 pipe_waitexit "M18: the client of the default session"
172 wait_sessions "$SOCK21" 0 "M18: every one of the three shells has exited"
173
174 # The daemon is asked of the OS, not of itself: `stats` answering above
175 # already needed a live daemon, but only a pid can say the PROCESS is still
176 # here. The two seconds are not a wait for anything — the table emptied
177 # before `stats` said so — they are the window the old exit-on-empty arm
178 # used its grace on, so a daemon that still has one is caught here and not
179 # by the next scenario finding a dead socket.
180 sleep 2
181 kill -0 "$D18PID" 2>/dev/null || {
182 echo "e2e FAIL: M18: the daemon left with its last session — only muxd stop ends one"
183 cat "$OUT.m18.d"; exit 1; }
184
185 # And it can be born into again: a bare `mux --sock` names no session, so it
186 # asks for the default, and an EMPTY daemon creates it rather than refusing.
187 # This is `mux HOST` reaching a box someone emptied yesterday.
188 pipe_mux "$OUT.m18rb" "$OUT.m18rb.err" timeout 40 "$MUX" --sock "$SOCK21"
189 pipe_send 'printf "m18rb-%%s\\n" pin\n'
190 await_out "$OUT.m18rb" "m18rb-pin" "m18rb-pin never reached the reborn default session"
191 pipe_detach
192 wait_sessions "$SOCK21" 1 "M18: the emptied daemon was born into again"
193 wait_grid "$SOCK21" "m18rb-pin" "M18: the reborn default session's marker"
194
162 assert_stopped "$SOCK21" "$D18PID" "M18 multi-session" "$OUT.m18stop" 195 assert_stopped "$SOCK21" "$D18PID" "M18 multi-session" "$OUT.m18stop"
163 D18PID="" 196 D18PID=""
164 ok "two sessions on one daemon are two shells; one dies without the other" 197 ok "two sessions on one daemon are two shells; one dies without the other"
198 ok "a daemon outlives its last session and is born into again"
165 199
166 # --- M18 on the wall: one socket, two tiles, two sessions --------------- 200 # --- M18 on the wall: one socket, two tiles, two sessions ---------------
167 # What the milestone was FOR (decision 2): the wall showing the same host 201 # What the milestone was FOR (decision 2): the wall showing the same host
test/e2e_10_agent.sh
Old New
@@ -165,11 +165,13 @@ ok "agent forwarding: -A with no agent is a usage error, not a silent no-op"
165 165
166 # --- ...and a session nobody offered an agent to refuses, fast ------------- 166 # --- ...and a session nobody offered an agent to refuses, fast -------------
167 # 167 #
168 # A fresh daemon on the freed path, and the spawn is load-bearing: the leg 168 # A fresh daemon on the path, and both halves of that are load-bearing. The
169 # above took its daemon down with its last session, so a client dialling 169 # leg above ended its last session but not its DAEMON — a daemon lives until
170 # $SOCK48 now would AUTO-START one off $PATH instead — a daemon that need 170 # `muxd stop` — so the old one is stopped by name here rather than waited
171 # not be the one under test, and against a release with no agent code in it 171 # out. And the new one is started before anything dials, because a client
172 # every assertion below passes having witnessed nothing. 172 # dialling an unserved $SOCK48 would AUTO-START one off $PATH instead — a
173 # daemon that need not be the one under test, and against a release with no
174 # agent code in it every assertion below passes having witnessed nothing.
173 # 175 #
174 # The sibling case — a session beside one that HAS an answerer — is the unit 176 # The sibling case — a session beside one that HAS an answerer — is the unit
175 # suite's; what this leg witnesses is the plainer shape, a live daemon 177 # suite's; what this leg witnesses is the plainer shape, a live daemon
@@ -181,7 +183,7 @@ ok "agent forwarding: -A with no agent is a usage error, not a silent no-op"
181 # gap: ssh reads a closed agent socket as "agent refused operation" and 183 # gap: ssh reads a closed agent socket as "agent refused operation" and
182 # falls straight through to its other methods, where a connection accepted 184 # falls straight through to its other methods, where a connection accepted
183 # and left silent would make it wait out a timeout on every dial. 185 # and left silent would make it wait out a timeout on every dial.
184 wait_pid_gone "$D42PID" "agent forwarding: the positive leg's daemon outlived its last session" 186 assert_stopped "$SOCK48" "$D42PID" "agent forwarding: the positive leg's daemon" "$OUT.agtstop0"
185 start_daemon "$SOCK48" "$OUT.agtn.d" "agent-refusal daemon never bound" --shell /bin/sh 187 start_daemon "$SOCK48" "$OUT.agtn.d" "agent-refusal daemon never bound" --shell /bin/sh
186 D42PID=$DPID 188 D42PID=$DPID
187 AR0=$(date +%s%N) 189 AR0=$(date +%s%N)
@@ -461,13 +463,12 @@ set -e
461 # whatever routing did: they replicate one grid, so each capture holds both 463 # whatever routing did: they replicate one grid, so each capture holds both
462 # answers by the time the session ends. 464 # answers by the time the session ends.
463 465
464 # wait_pid_gone and not assert_stopped, because there is nothing left to 466 # A typed `exit` ended this daemon's only session and the daemon is still
465 # stop: A typed `exit`, that was this daemon's only session, and a daemon 467 # here: emptiness is not an exit, and only `muxd stop` ends one. So this is
466 # leaves with its last one (server.zig pumpOnce). The positive leg above 468 # assert_stopped like every other teardown — the kill -0 that says the
467 # ends on assert_stopped only because its second scenario auto-started a 469 # process really went is inside it (wait_pid_gone), and the empty daemon
468 # fresh daemon on the same path to stop. What is asserted here is the same 470 # had to answer the stop to get there.
469 # thing either way — the process this leg forked is gone. 471 assert_stopped "$SOCK49" "$D43PID" "agent-flip" "$OUT.flipstop"
470 wait_pid_gone "$D43PID" "agent-flip: the session ended and the daemon should follow"
471 D43PID="" 472 D43PID=""
472 # Ended here rather than in the trap, for the reason the leg above gives: 473 # Ended here rather than in the trap, for the reason the leg above gives:
473 # a green run should not leave two key-holding processes alive for the 474 # a green run should not leave two key-holding processes alive for the
test/e2e_11_select.sh
Old New
@@ -432,6 +432,11 @@ MUTE2MS=$(sed -n 's/.*mute2=[0-9]* ms=\([0-9]*\).*/\1/p' "$OUT.agtmute" | head -
432 echo "e2e FAIL: agent-mute: the second ssh-add took ${MUTE2MS}ms — the mute" 432 echo "e2e FAIL: agent-mute: the second ssh-add took ${MUTE2MS}ms — the mute"
433 echo " client kept its offer and ssh paid the bound again" 433 echo " client kept its offer and ssh paid the bound again"
434 cat -v "$OUT.agtmute"; exit 1; } 434 cat -v "$OUT.agtmute"; exit 1; }
435 wait_pid_gone "$D42PID" "agent-mute: the session ended and the daemon should follow" 435 # assert_stopped, not wait_pid_gone: the typed `exit` ended this daemon's
436 # only session and emptiness is not an exit — the daemon is still here and
437 # has to be told. The kill -0 that says the process really went is inside
438 # assert_stopped (wait_pid_gone), so nothing is given up by asking for the
439 # stop first.
440 assert_stopped "$SOCK48" "$D42PID" "agent-mute" "$OUT.agtmutestop"
436 D42PID="" 441 D42PID=""
437 ok "agent forwarding: a mute offerer is hung up on, then no longer offered (${MUTE2MS}ms)" 442 ok "agent forwarding: a mute offerer is hung up on, then no longer offered (${MUTE2MS}ms)"