code wiki / _hdl_build / nx_ts_handoff_gate.nx
nx_ts_handoff_gate.nx source
↩ module page · 413 lines · 17645 B
1// nx_ts_handoff_gate.nx -- TS1: KEEP THE SOCKET, REPLACE THE PROCESS.
2//
3// /compare/trafficsafety rung TS1, accept rule taken VERBATIM from trafficsafety.plan and not
4// re-invented here: "a swap under a synthetic request stream drops ZERO connections, measured by a
5// client that counts refusals and resets, not by the daemon's own log. The neg-control is mandatory
6// -- the same harness against the CURRENT ordinary-listener path must show a non-zero drop, or the
7// test proves nothing."
8//
9// THE MEASUREMENT IS DROPPED CONNECTIONS, NOT BINDERS. nx_hotlisten_gate proves two processes can
10// co-bind a port, which is the NECESSARY condition and never the sufficient one -- LWN documents
11// SO_REUSEPORT as itself dropping connections during the three-way handshake when the listening set
12// changes. So nothing here counts binders. A client process drives real TCP connections, and every
13// connect that is refused and every exchange that does not complete is counted as a DROP.
14//
15// THE DECISIVE TOOTH IS THE ARRIVAL DURING THE GAP. Both arms run the same script: generation one
16// serves a stream, generation one EXITS AND IS REAPED so that zero servers exist, a client connects
17// at that exact instant, and only then is generation two started. With an owner outside the service
18// holding the listener the arrival completes its handshake into the kernel's accept queue and is
19// served microseconds later; with the current bind-per-process path there is nothing bound and the
20// arrival is refused. That difference is deterministic, which is why it can be a gate.
21//
22// SCOPE DECLARED UP FRONT RATHER THAN IMPLIED. This proves the STRUCTURAL property -- an arrival
23// during the swap is not refused. It is NOT a failure-RATE measurement: HAProxy's published figure
24// for the un-fixed race is 155 failures per million connections, and no run of this size could see
25// a rate like that. Measuring that would need a load generator and is a different instrument; the
26// ceiling is stated here rather than left for a reader to assume it was covered.
27//
28// SAFETY: loopback only, and every port is FOUND by a bind that would have failed if the port were
29// occupied -- a probe port you did not verify free is not a control, it is a second instance. No
30// serving daemon is touched.
31// expect_exit: 0 license_tier: ORIGINAL No hw writes (Rule 26).
32import "nx_syscalls.nx"
33import "nx_http_server.nx"
34import "nx_gate_emit_lib.nx"
35import "nx_gate_verdict.nx"
36
37const TH_NUM_SCRATCH: i64 = 24
38const TH_ASCII_ZERO: i64 = 48
39const TH_B10: i64 = 10
40const TH_ONE_BYTE: i64 = 1
41const TH_SA_IN_BYTES: i64 = 16
42const TH_BYTE_RADIX: i64 = 256
43const TH_OUT_SLOTS: i64 = 8
44const TH_OUT_BYTES: i64 = 64
45const TH_LOOPBACK_A: i64 = 127
46const TH_LOOPBACK_D: i64 = 1
47const TH_PORT_BASE: i64 = 39100
48const TH_PORT_TRIES: i64 = 64
49const TH_PORT_NONE: i64 = 0 - 1
50const TH_PING_BYTE: i64 = 80
51const TH_ECHO_BYTE: i64 = 90
52const TH_READY_BYTE: i64 = 82
53const TH_BOOL_TRUE: i64 = 1
54const TH_BOOL_FALSE: i64 = 0
55// The synthetic stream's size per generation is taken from the rendezvous backlog rather than
56// picked: it exercises the accept queue at the queue's own scale on both sides of the swap. The
57// decisive tooth does not depend on it -- the in-gap arrival is a single deterministic event -- so
58// this number sets how much "the server was really serving" evidence surrounds that event.
59const TH_REQS_PER_GEN: i64 = TS_RV_BACKLOG
60// Bounds exist only so a broken run fails LOUD instead of hanging. Reusing the shim's established
61// socket deadline rather than inventing a second budget for the same kind of wait.
62const TH_DEADLINE_S: i64 = ACCEPT_TMO_S
63const TH_CHILD_ALARM_S: i64 = ACCEPT_TMO_S * 2
64// Out-slot layout for ts_swap_run.
65const TH_O_ATTEMPTED: i64 = 0
66const TH_O_SERVED: i64 = 1
67const TH_O_GAP_RC: i64 = 2
68const TH_O_GAP_DONE: i64 = 3
69const TH_O_PORT: i64 = 4
70const TH_O_C1: i64 = 5
71const TH_O_C2: i64 = 6
72const TH_GAP_UNRUN: i64 = 0 - 999
73// Child exit codes -- each names WHICH step failed.
74const TH_C_OK: i64 = 0
75const TH_C_ACQUIRE: i64 = 2
76const TH_C_BIND: i64 = 3
77const TH_C_READY: i64 = 4
78const TH_C_ACCEPT: i64 = 5
79const TH_C_BADREQ: i64 = 6
80const TH_C_WRITE: i64 = 7
81const TH_SOCK_ARMED: *u8 = "/tmp/nx_ts_handoff_gate/armed.sock" as *u8
82const TH_SOCK_NEG: *u8 = "/tmp/nx_ts_handoff_gate/neg.sock" as *u8
83const TH_DIR: *u8 = "/tmp/nx_ts_handoff_gate" as *u8
84
85func g_putn(v: i64) -> i64 {
86 if v == 0 { sys_write(1, "0" as *u8, 1); return 0 }
87 var m: i64 = v
88 if m < 0 { sys_write(1, "-" as *u8, 1); m = 0 - m }
89 let d: *u8 = sys_mmap(TH_NUM_SCRATCH); var k: i64 = 0
90 while m > 0 { d[k] = ((TH_ASCII_ZERO + (m - (m / TH_B10) * TH_B10)) as u8); m = m / TH_B10; k = k + 1 }
91 var j: i64 = k - 1
92 while j >= 0 { sys_write(1, ((d as i64)+j) as *u8, 1); j = j - 1 }
93 return 0
94}
95
96func gq(label: *u8, got: i64, want: i64, ctr: *i64) -> i64 {
97 var c: i64 = 0
98 if got == want { c = 1 }
99 let r: i64 = gv_check(label, c, ctr)
100 if c == 0 {
101 g_puts(" got=" as *u8); g_putn(got)
102 g_puts(" want=" as *u8); g_putn(want)
103 g_puts("\n" as *u8)
104 }
105 return r
106}
107
108func th_sa(sa: *u8, port: i64) -> i64 {
109 var i: i64 = 0
110 while i < TH_SA_IN_BYTES { sa[i] = 0; i = i + 1 }
111 sa[0] = AF_INET
112 sa[1] = 0
113 sa[2] = port / TH_BYTE_RADIX
114 sa[3] = port % TH_BYTE_RADIX
115 sa[4] = TH_LOOPBACK_A
116 sa[5] = 0
117 sa[6] = 0
118 sa[7] = TH_LOOPBACK_D
119 return 0
120}
121
122func th_put1(fd: i64, v: i64) -> i64 {
123 let b: *u8 = sys_mmap(TH_ONE_BYTE)
124 b[0] = v
125 return sys_write(fd, b, TH_ONE_BYTE)
126}
127func th_get1(fd: i64) -> i64 {
128 let b: *u8 = sys_mmap(TH_ONE_BYTE)
129 b[0] = 0
130 let r: i64 = sys_read(fd, b, TH_ONE_BYTE)
131 if r != TH_ONE_BYTE { return 0 - 1 }
132 return b[0] as i64
133}
134
135// Bind+listen loopback:<port>. reuse=1 sets SO_REUSEADDR. With reuse=0 the bind ITSELF is the
136// free-port check, which is why the port search below never sets it.
137func th_listen(port: i64, reuse: i64) -> i64 {
138 let fd: i64 = sys_socket(AF_INET, SOCK_STREAM, 0)
139 if fd < 0 { return fd }
140 if reuse == TH_BOOL_TRUE {
141 let ra: *u8 = sys_mmap(SCM_U32_BYTES)
142 ra[0] = 1
143 ra[1] = 0
144 ra[2] = 0
145 ra[3] = 0
146 sys_setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, ra, SCM_U32_BYTES)
147 }
148 let sa: *u8 = sys_mmap(TH_SA_IN_BYTES)
149 th_sa(sa, port)
150 if sys_bind(fd, sa, TH_SA_IN_BYTES) < 0 { sys_close(fd); return 0 - 1 }
151 if sys_listen(fd, TS_RV_BACKLOG) < 0 { sys_close(fd); return 0 - 1 }
152 return fd
153}
154
155// Find a free loopback port at or above `from` by BINDING it without SO_REUSEADDR, then releasing
156// it. A port that binds was free; a port that refuses the bind was not.
157func th_find_port(from: i64) -> i64 {
158 var t: i64 = 0
159 while t < TH_PORT_TRIES {
160 let cand: i64 = from + t
161 let fd: i64 = th_listen(cand, TH_BOOL_FALSE)
162 if fd >= 0 { sys_close(fd); return cand }
163 t = t + 1
164 }
165 return TH_PORT_NONE
166}
167
168// One client exchange on an already-connected socket. 1 = served, 0 = dropped.
169func th_exchange(fd: i64) -> i64 {
170 if th_put1(fd, TH_PING_BYTE) != TH_ONE_BYTE { return 0 }
171 if th_get1(fd) != TH_ECHO_BYTE { return 0 }
172 return 1
173}
174
175// One full client connection. 1 = served, 0 = dropped (refused, reset, or an incomplete exchange).
176func th_drive_one(port: i64) -> i64 {
177 let sa: *u8 = sys_mmap(TH_SA_IN_BYTES)
178 th_sa(sa, port)
179 let fd: i64 = sys_socket(AF_INET, SOCK_STREAM, 0)
180 if fd < 0 { return 0 }
181 sys_set_socket_timeout(fd, TH_DEADLINE_S)
182 if sys_connect(fd, sa, TH_SA_IN_BYTES) < 0 { sys_close(fd); return 0 }
183 let ok: i64 = th_exchange(fd)
184 sys_close(fd)
185 return ok
186}
187
188// A SERVER GENERATION. armed=1 acquires the listener from the owner and never binds; armed=0 is the
189// CURRENT path -- it binds the port itself and releases it on exit, which is the disease state.
190func th_generation(armed: i64, port: i64, sock_path: *u8, reqs: i64) -> i64 {
191 sys_alarm(TH_CHILD_ALARM_S)
192 let vb: *i64 = (sys_mmap(TH_OUT_BYTES)) as *i64
193 vb[0] = 0
194 var lfd: i64 = 0 - 1
195 if armed == TH_BOOL_TRUE {
196 lfd = ts_handoff_acquire(sock_path, vb)
197 if lfd < 0 { return TH_C_ACQUIRE }
198 } else {
199 lfd = th_listen(port, TH_BOOL_TRUE)
200 if lfd < 0 { return TH_C_BIND }
201 // Signal readiness over the SAME rendezvous the armed arm uses, so the two arms differ in
202 // exactly one thing -- where the listener came from -- and in nothing else.
203 let rc: i64 = sys_unix_connect_fd(sock_path)
204 if rc < 0 { return TH_C_READY }
205 if th_put1(rc, TH_READY_BYTE) != TH_ONE_BYTE { return TH_C_READY }
206 sys_close(rc)
207 }
208 sys_set_socket_timeout(lfd, TH_DEADLINE_S)
209 var i: i64 = 0
210 while i < reqs {
211 let acc: i64 = sys_accept(lfd)
212 if acc < 0 { return TH_C_ACCEPT }
213 sys_set_socket_timeout(acc, TH_DEADLINE_S)
214 if th_get1(acc) != TH_PING_BYTE { sys_close(acc); return TH_C_BADREQ }
215 if th_put1(acc, TH_ECHO_BYTE) != TH_ONE_BYTE { sys_close(acc); return TH_C_WRITE }
216 sys_close(acc)
217 i = i + 1
218 }
219 // armed: this releases only THIS generation's copy, and the owner still holds the socket.
220 // neg-control: this releases the port itself, which is the whole point of the control.
221 sys_close(lfd)
222 return TH_C_OK
223}
224
225// The owner's side of one generation start: hand over the listener (armed) or read the ready byte
226// (neg-control). Returns 1 on success.
227func th_serve_rv(rv: i64, listen_fd: i64, armed: i64) -> i64 {
228 if armed == TH_BOOL_TRUE {
229 let vb: *i64 = (sys_mmap(TH_OUT_BYTES)) as *i64
230 vb[0] = 0
231 if ts_handoff_publish(rv, listen_fd, vb) < 0 { return 0 }
232 return 1
233 }
234 let c: i64 = sys_accept(rv)
235 if c < 0 { return 0 }
236 sys_set_socket_timeout(c, TH_DEADLINE_S)
237 let b: i64 = th_get1(c)
238 sys_close(c)
239 if b != TH_READY_BYTE { return 0 }
240 return 1
241}
242
243// THE EXPERIMENT. Identical script for both arms; the ONLY difference is who owns the listener.
244// Returns the number of DROPPED connections, or -1 if the harness could not be set up at all --
245// which is reported as UNOBSERVED, never as a pass.
246func ts_swap_run(armed: i64, port: i64, sock_path: *u8, out: *i64) -> i64 {
247 var k: i64 = 0
248 while k < TH_OUT_SLOTS { out[k] = 0; k = k + 1 }
249 out[TH_O_GAP_RC] = TH_GAP_UNRUN
250 out[TH_O_PORT] = port
251 let vb: *i64 = (sys_mmap(TH_OUT_BYTES)) as *i64
252 vb[0] = 0
253 let rv: i64 = ts_handoff_open(sock_path, vb)
254 if rv < 0 { return 0 - 1 }
255 sys_set_socket_timeout(rv, TH_DEADLINE_S)
256 var lsock: i64 = 0 - 1
257 if armed == TH_BOOL_TRUE {
258 lsock = th_listen(port, TH_BOOL_FALSE)
259 if lsock < 0 { sys_close(rv); sys_unlinkat(sock_path); return 0 - 1 }
260 }
261 var drops: i64 = 0
262 var attempted: i64 = 0
263 var served: i64 = 0
264
265 // ---- generation one
266 let c1: i64 = sys_fork()
267 if c1 == 0 { sys_exit(th_generation(armed, port, sock_path, TH_REQS_PER_GEN)) }
268 if th_serve_rv(rv, lsock, armed) == 0 {
269 sys_close(rv)
270 if lsock >= 0 { sys_close(lsock) }
271 sys_unlinkat(sock_path)
272 return 0 - 1
273 }
274 var i: i64 = 0
275 while i < TH_REQS_PER_GEN {
276 attempted = attempted + 1
277 let ok: i64 = th_drive_one(port)
278 if ok == 1 { served = served + 1 } else { drops = drops + 1 }
279 i = i + 1
280 }
281 let st: *i64 = (sys_mmap(TH_OUT_BYTES)) as *i64
282 st[0] = 0
283 sys_wait4(c1, st, 0)
284 out[TH_O_C1] = wait_status_rc(st[0])
285
286 // ---- THE GAP. Generation one has exited and been reaped: at this instant ZERO server
287 // processes exist. An arrival now is the whole experiment.
288 let sa: *u8 = sys_mmap(TH_SA_IN_BYTES)
289 th_sa(sa, port)
290 let gap: i64 = sys_socket(AF_INET, SOCK_STREAM, 0)
291 sys_set_socket_timeout(gap, TH_DEADLINE_S)
292 let grc: i64 = sys_connect(gap, sa, TH_SA_IN_BYTES)
293 out[TH_O_GAP_RC] = grc
294 out[TH_O_GAP_DONE] = 1
295 attempted = attempted + 1
296 if grc < 0 { drops = drops + 1 }
297
298 // ---- generation two. It must also serve the in-gap arrival when that arrival survived.
299 var gen2: i64 = TH_REQS_PER_GEN
300 if grc == 0 { gen2 = gen2 + 1 }
301 let c2: i64 = sys_fork()
302 if c2 == 0 { sys_exit(th_generation(armed, port, sock_path, gen2)) }
303 let sv2: i64 = th_serve_rv(rv, lsock, armed)
304 if grc == 0 {
305 if sv2 == 1 {
306 if th_exchange(gap) == 1 { served = served + 1 } else { drops = drops + 1 }
307 } else { drops = drops + 1 }
308 }
309 sys_close(gap)
310 if sv2 == 1 {
311 var j: i64 = 0
312 while j < TH_REQS_PER_GEN {
313 attempted = attempted + 1
314 let ok2: i64 = th_drive_one(port)
315 if ok2 == 1 { served = served + 1 } else { drops = drops + 1 }
316 j = j + 1
317 }
318 }
319 st[0] = 0
320 sys_wait4(c2, st, 0)
321 out[TH_O_C2] = wait_status_rc(st[0])
322
323 if lsock >= 0 { sys_close(lsock) }
324 sys_close(rv)
325 sys_unlinkat(sock_path)
326 out[TH_O_ATTEMPTED] = attempted
327 out[TH_O_SERVED] = served
328 return drops
329}
330
331func th_report(tag: *u8, drops: i64, out: *i64) -> i64 {
332 g_puts(" " as *u8); g_puts(tag)
333 g_puts(" port=" as *u8); g_putn(out[TH_O_PORT])
334 g_puts(" attempted=" as *u8); g_putn(out[TH_O_ATTEMPTED])
335 g_puts(" served=" as *u8); g_putn(out[TH_O_SERVED])
336 g_puts(" drops=" as *u8); g_putn(drops)
337 g_puts(" in_gap_connect_rc=" as *u8); g_putn(out[TH_O_GAP_RC])
338 g_puts(" gen1_exit=" as *u8); g_putn(out[TH_O_C1])
339 g_puts(" gen2_exit=" as *u8); g_putn(out[TH_O_C2])
340 g_puts("\n" as *u8)
341 return 0
342}
343
344func main() -> i64 {
345 let ctr: *i64 = gv_ctr()
346 gv_head("nx_ts_handoff_gate -- TS1: zero dropped connections across a process swap" as *u8)
347
348 sys_mkdir(TH_DIR, MODE_0755)
349 sys_unlinkat(TH_SOCK_ARMED)
350 sys_unlinkat(TH_SOCK_NEG)
351
352 let pa: i64 = th_find_port(TH_PORT_BASE)
353 var pn: i64 = TH_PORT_NONE
354 if pa != TH_PORT_NONE { pn = th_find_port(pa + 1) }
355 var ports_ok: i64 = 0
356 if pa != TH_PORT_NONE { if pn != TH_PORT_NONE { if pa != pn { ports_ok = 1 } } }
357 if gv_need("two DISTINCT loopback ports proven free by a bind without SO_REUSEADDR" as *u8, ports_ok, ctr) == 0 {
358 let rcs: i64 = gv_verdict("TS-HANDOFF-GATE" as *u8, ctr, "each tooth states its own strength above" as *u8)
359 sys_exit(rcs)
360 return rcs
361 }
362 gq("T01 the two arms run on DIFFERENT ports so neither can serve the other" as *u8, pa != pn, 1, ctr)
363
364 // ---- ARM 1: the owner outside the service holds the listener.
365 let oa: *i64 = (sys_mmap(TH_OUT_BYTES)) as *i64
366 let da: i64 = ts_swap_run(TH_BOOL_TRUE, pa, TH_SOCK_ARMED, oa)
367 th_report("ARMED " as *u8, da, oa)
368 var arm_ran: i64 = 0
369 if da >= 0 { if oa[TH_O_GAP_DONE] == 1 { arm_ran = 1 } }
370 if gv_need("the ARMED arm reached the swap with a live client stream" as *u8, arm_ran, ctr) == 1 {
371 // ANTI-VACUITY FIRST: a swap with nothing in flight is UNOBSERVED, never a pass. The
372 // denominator is asserted arithmetically before any verdict about drops is believed.
373 gq("T02 anti-vacuity the ARMED arm actually drove the whole stream" as *u8,
374 oa[TH_O_ATTEMPTED], TH_REQS_PER_GEN + TH_REQS_PER_GEN + 1, ctr)
375 gq("T03 anti-vacuity generation one really exited before the gap" as *u8, oa[TH_O_C1], TH_C_OK, ctr)
376 gq("T04 THE INVARIANT an arrival DURING the swap is not refused" as *u8, oa[TH_O_GAP_RC], 0, ctr)
377 gq("T05 THE ACCEPT RULE zero dropped connections across the swap" as *u8, da, 0, ctr)
378 gq("T06 and every attempted connection was served end to end" as *u8, oa[TH_O_SERVED], oa[TH_O_ATTEMPTED], ctr)
379 gq("T07 generation two completed its share including the in-gap arrival" as *u8, oa[TH_O_C2], TH_C_OK, ctr)
380 }
381
382 // ---- ARM 2: NEG-CONTROL. The same harness against the CURRENT bind-per-process path. If this
383 // does not drop, the armed arm's zero proves nothing at all.
384 let on: *i64 = (sys_mmap(TH_OUT_BYTES)) as *i64
385 let dn: i64 = ts_swap_run(TH_BOOL_FALSE, pn, TH_SOCK_NEG, on)
386 th_report("NEG-CTRL" as *u8, dn, on)
387 var neg_ran: i64 = 0
388 if dn >= 0 { if on[TH_O_GAP_DONE] == 1 { neg_ran = 1 } }
389 if gv_need("the NEG-CONTROL arm reached the swap with a live client stream" as *u8, neg_ran, ctr) == 1 {
390 gq("T08 anti-vacuity the neg-control drove the same number of connections" as *u8,
391 on[TH_O_ATTEMPTED], oa[TH_O_ATTEMPTED], ctr)
392 var refused: i64 = 0
393 if on[TH_O_GAP_RC] < 0 { refused = 1 }
394 gq("T09 neg-control-bind-per-process the in-gap arrival IS refused" as *u8, refused, 1, ctr)
395 var lost: i64 = 0
396 if dn > 0 { lost = 1 }
397 gq("T10 neg-control-drops the current path loses connections across a swap" as *u8, lost, 1, ctr)
398 // The control must fail for the RIGHT reason: the port went away, not the harness broke.
399 gq("T11 neg-control-attribution its generations both ran cleanly" as *u8,
400 on[TH_O_C1] + on[TH_O_C2], TH_C_OK, ctr)
401 // Both signals present at once: two arms that each isolate one signal do not prove the
402 // mechanism discriminates between them.
403 var better: i64 = 0
404 if da >= 0 { if dn > da { better = 1 } }
405 gq("T12 the handoff arm strictly beats the current path on the SAME harness" as *u8, better, 1, ctr)
406 }
407
408 sys_unlinkat(TH_SOCK_ARMED)
409 sys_unlinkat(TH_SOCK_NEG)
410 let rc: i64 = gv_verdict("TS-HANDOFF-GATE" as *u8, ctr, "each tooth states its own strength above" as *u8)
411 sys_exit(rc)
412 return rc
413}