a73x

8a3f8a6a

refactor: one counter struct, upgrade.Counters, on both sides of the exec

a73x   2026-08-29 10:01

Commit message
refactor: one counter struct, upgrade.Counters, on both sides of the exec

server.zig's private `Stats` was six of upgrade.Counters's eight fields,
and the two manifest crossings copied them field by field in both
directions. `stats: upgrade.Counters` makes the adoption crossing
`srv.stats = d.counters` and the write side a copy with the relay's two
refusal counters stamped over it, so a counter added to the manifest can
no longer be carried on one side and dropped on the other. The field
prose travels with its fields.

Pinned by upgrade.zig's manifest round-trip test, watched to fail once:
`+% 1` on the write side's inline for turned it red on snapshots=5,
reverted by the inverse edit.

src/server/server.zig
Old New
@@ -131,33 +131,6 @@ pub fn boundUdpPort(l: *quic_server.Listener) u16 {
131 return std.net.Address.initPosix(@ptrCast(@alignCast(&actual))).getPort(); 131 return std.net.Address.initPosix(@ptrCast(@alignCast(&actual))).getPort();
132 } 132 }
133 133
134 const Stats = struct {
135 snapshots: u64 = 0,
136 snapshot_bytes: u64 = 0,
137 deltas: u64 = 0,
138 delta_bytes: u64 = 0,
139 /// What the same updates would have cost a snapshot-only daemon:
140 /// measured, not estimated — dumpState length at each delta send.
141 snapshot_equiv_bytes: u64 = 0,
142 /// Every `.attach` this daemon ACCEPTED, cumulative and monotonic.
143 ///
144 /// A counter, deliberately, where `clients=` is a gauge. The gauge
145 /// answers "who is watching right now" and cannot answer "did anyone
146 /// attach at all since I last looked": a client that attaches and
147 /// leaves between two samples is invisible to it, and so is one that
148 /// closes as another opens. The CLI wall's in-place zoom is exactly
149 /// the claim that needs the second question answered — it promotes a
150 /// connection it already holds, so the honest proof is that this
151 /// number did not move across a zoom, a skip and an unzoom.
152 ///
153 /// Counted where an attach SUCCEEDS (a session resolved and the client
154 /// is seated), not where the frame arrives: a refusal attached nobody.
155 /// Both arms count, because a socket client's first attach promotes an
156 /// observer and every later one — a QUIC first attach included —
157 /// arrives on an established connection.
158 attaches: u64 = 0,
159 };
160
161 pub var shutdown_flag = std.atomic.Value(bool).init(false); 134 pub var shutdown_flag = std.atomic.Value(bool).init(false);
162 135
163 fn onShutdownSignal(_: c_int) callconv(.c) void { 136 fn onShutdownSignal(_: c_int) callconv(.c) void {
@@ -559,7 +532,7 @@ pub const Server = struct {
559 /// directory the per-session sockets are bound in. See server_agent.zig 532 /// directory the per-session sockets are bound in. See server_agent.zig
560 /// for why it is handed a `*Server` rather than holding one. 533 /// for why it is handed a `*Server` rather than holding one.
561 agents: AgentRelay = .{}, 534 agents: AgentRelay = .{},
562 stats: Stats = .{}, 535 stats: upgrade.Counters = .{},
563 // Set by the upgrade_req handler, checked by the run loop after each 536 // Set by the upgrade_req handler, checked by the run loop after each
564 // pump: the reply must drain before the exec, and the exec must happen 537 // pump: the reply must drain before the exec, and the exec must happen
565 // outside the frame handler (mid-handler exec would strand the observer 538 // outside the frame handler (mid-handler exec would strand the observer
@@ -745,14 +718,7 @@ pub const Server = struct {
745 // anything sampling `mux d stats`. Saturating rather than @intCast 718 // anything sampling `mux d stats`. Saturating rather than @intCast
746 // below: a manifest is bytes, and a corrupt counter must not panic a 719 // below: a manifest is bytes, and a corrupt counter must not panic a
747 // daemon that is otherwise able to serve. 720 // daemon that is otherwise able to serve.
748 srv.stats = .{ 721 srv.stats = d.counters;
749 .snapshots = d.counters.snapshots,
750 .snapshot_bytes = d.counters.snapshot_bytes,
751 .deltas = d.counters.deltas,
752 .delta_bytes = d.counters.delta_bytes,
753 .snapshot_equiv_bytes = d.counters.snapshot_equiv_bytes,
754 .attaches = d.counters.attaches,
755 };
756 srv.agents.refused_no_offer = 722 srv.agents.refused_no_offer =
757 std.math.cast(u32, d.counters.agent_refused_no_offer) orelse std.math.maxInt(u32); 723 std.math.cast(u32, d.counters.agent_refused_no_offer) orelse std.math.maxInt(u32);
758 srv.agents.refused_full = 724 srv.agents.refused_full =
@@ -2061,7 +2027,7 @@ pub const Server = struct {
2061 return; 2027 return;
2062 }; 2028 };
2063 // Past the refusal, so the counter never grows for an 2029 // Past the refusal, so the counter never grows for an
2064 // attach that attached nobody. See Stats.attaches. 2030 // attach that attached nobody. See upgrade.Counters.attaches.
2065 self.stats.attaches += 1; 2031 self.stats.attaches += 1;
2066 if (self.clients[i]) |*c| { 2032 if (self.clients[i]) |*c| {
2067 // An await's since_seq is a watermark in the OLD 2033 // An await's since_seq is a watermark in the OLD
@@ -2531,7 +2497,7 @@ pub const Server = struct {
2531 return self.refuseObserver(i); 2497 return self.refuseObserver(i);
2532 }; 2498 };
2533 // Past both refusals above, so the counter only ever grows 2499 // Past both refusals above, so the counter only ever grows
2534 // for an attach that seated somebody. See Stats.attaches. 2500 // for an attach that seated somebody. See upgrade.Counters.attaches.
2535 self.stats.attaches += 1; 2501 self.stats.attaches += 1;
2536 // Promote: the buffer moves with the fd. 2502 // Promote: the buffer moves with the fd.
2537 const moved = self.observers[i].?.inbound; 2503 const moved = self.observers[i].?.inbound;
@@ -3259,6 +3225,12 @@ pub const Server = struct {
3259 // The relay hands over its counters as one value; nothing here reads 3225 // The relay hands over its counters as one value; nothing here reads
3260 // its fields, so the manifest cannot drift when the table's shape does. 3226 // its fields, so the manifest cannot drift when the table's shape does.
3261 const ac = self.agents.counters(); 3227 const ac = self.agents.counters();
3228 // AgentRelay owns the two refusal counters; `self.stats` only ever
3229 // carried them, so they are stamped from the relay on the way out
3230 // and whatever adoption left in the carrier is overwritten here.
3231 var counters = self.stats;
3232 counters.agent_refused_no_offer = ac.refused_no_offer;
3233 counters.agent_refused_full = ac.refused_full;
3262 const daemon: upgrade.Daemon = .{ 3234 const daemon: upgrade.Daemon = .{
3263 .writer_version = writer_version, 3235 .writer_version = writer_version,
3264 .writer_path = writer_path, 3236 .writer_path = writer_path,
@@ -3270,16 +3242,7 @@ pub const Server = struct {
3270 .shell_integration = self.spawn_shell_integration, 3242 .shell_integration = self.spawn_shell_integration,
3271 .extra_env = extra_env, 3243 .extra_env = extra_env,
3272 .quic = quic_state, 3244 .quic = quic_state,
3273 .counters = .{ 3245 .counters = counters,
3274 .snapshots = self.stats.snapshots,
3275 .snapshot_bytes = self.stats.snapshot_bytes,
3276 .deltas = self.stats.deltas,
3277 .delta_bytes = self.stats.delta_bytes,
3278 .snapshot_equiv_bytes = self.stats.snapshot_equiv_bytes,
3279 .attaches = self.stats.attaches,
3280 .agent_refused_no_offer = ac.refused_no_offer,
3281 .agent_refused_full = ac.refused_full,
3282 },
3283 }; 3246 };
3284 3247
3285 // One SessionRec per live session. dumpState is viewport-only by 3248 // One SessionRec per live session. dumpState is viewport-only by
src/server/upgrade.zig
Old New
@@ -72,7 +72,25 @@ pub const Counters = struct {
72 snapshot_bytes: u64 = 0, 72 snapshot_bytes: u64 = 0,
73 deltas: u64 = 0, 73 deltas: u64 = 0,
74 delta_bytes: u64 = 0, 74 delta_bytes: u64 = 0,
75 /// What the same updates would have cost a snapshot-only daemon:
76 /// measured, not estimated — dumpState length at each delta send.
75 snapshot_equiv_bytes: u64 = 0, 77 snapshot_equiv_bytes: u64 = 0,
78 /// Every `.attach` this daemon ACCEPTED, cumulative and monotonic.
79 ///
80 /// A counter, deliberately, where `clients=` is a gauge. The gauge
81 /// answers "who is watching right now" and cannot answer "did anyone
82 /// attach at all since I last looked": a client that attaches and
83 /// leaves between two samples is invisible to it, and so is one that
84 /// closes as another opens. The CLI wall's in-place zoom is exactly
85 /// the claim that needs the second question answered — it promotes a
86 /// connection it already holds, so the honest proof is that this
87 /// number did not move across a zoom, a skip and an unzoom.
88 ///
89 /// Counted where an attach SUCCEEDS (a session resolved and the client
90 /// is seated), not where the frame arrives: a refusal attached nobody.
91 /// Both arms count, because a socket client's first attach promotes an
92 /// observer and every later one — a QUIC first attach included —
93 /// arrives on an established connection.
76 attaches: u64 = 0, 94 attaches: u64 = 0,
77 agent_refused_no_offer: u64 = 0, 95 agent_refused_no_offer: u64 = 0,
78 agent_refused_full: u64 = 0, 96 agent_refused_full: u64 = 0,
@@ -357,7 +375,16 @@ fn sampleDaemon() Daemon {
357 .shell_integration = true, 375 .shell_integration = true,
358 .extra_env = &.{ .{ .key = "FOO", .value = "bar" }, .{ .key = "UNSET_ME", .value = null } }, 376 .extra_env = &.{ .{ .key = "FOO", .value = "bar" }, .{ .key = "UNSET_ME", .value = null } },
359 .quic = q, 377 .quic = q,
360 .counters = .{ .snapshots = 5, .attaches = 9 }, 378 .counters = .{
379 .snapshots = 5,
380 .snapshot_bytes = 6,
381 .deltas = 7,
382 .delta_bytes = 8,
383 .snapshot_equiv_bytes = 10,
384 .attaches = 9,
385 .agent_refused_no_offer = 11,
386 .agent_refused_full = 12,
387 },
361 }; 388 };
362 } 389 }
363 390
@@ -428,8 +455,10 @@ test "manifest: a round-trip loses nothing a session needs" {
428 try std.testing.expectEqual(QuicArm.owned, p.daemon.quic.arm); 455 try std.testing.expectEqual(QuicArm.owned, p.daemon.quic.arm);
429 try std.testing.expectEqual(@as(u8, 0xCD), p.daemon.quic.key[31]); 456 try std.testing.expectEqual(@as(u8, 0xCD), p.daemon.quic.key[31]);
430 try std.testing.expectEqual(@as(u64, 30_000), p.daemon.quic.idle_ms); 457 try std.testing.expectEqual(@as(u64, 30_000), p.daemon.quic.idle_ms);
431 try std.testing.expectEqual(@as(u64, 5), p.daemon.counters.snapshots); 458 // Whole-struct, and every field distinct in the sample: a per-field
432 try std.testing.expectEqual(@as(u64, 9), p.daemon.counters.attaches); 459 // assertion is blind to the counter it forgot, and the `inline for` on
460 // both sides means the one it forgets is the one that was added.
461 try std.testing.expectEqual(d.counters, p.daemon.counters);
433 462
434 const ss = sampleSessions(); 463 const ss = sampleSessions();
435 try std.testing.expectEqual(@as(usize, 2), p.sessions.len); 464 try std.testing.expectEqual(@as(usize, 2), p.sessions.len);