nx_ts_drain_lib.nx source
↩ module page · 199 lines · 10029 B
1// nx_ts_drain_lib.nx -- TS2: COOPERATIVE DRAIN ON TERM, with a DERIVED deadline.
2//
3// /compare/trafficsafety rung TS2. Accept rule taken VERBATIM from trafficsafety.plan and not
4// re-invented here: "On TERM the daemon STOPS ACCEPTING and KEEPS SERVING in-flight requests until
5// a declared deadline, then exits; the deadline is a named constant, never a literal. ACCEPT RULE:
6// with a request in flight at the moment TERM lands, the client receives a complete response and
7// not a 5xx or a reset, and a daemon that exits early FAILS the gate."
8//
9// THE PROCESS MUST KEEP SERVING AFTER TERM. Kubernetes states the race precisely: endpoint removal
10// happens AT THE SAME TIME AS shutdown begins, not before it, so a process that exits on TERM is
11// exactly what produces the client-visible 5xx. AWS names the cost from the other side: a target
12// that terminates the connection before the deregistration delay elapses makes the client receive a
13// 500-level error.
14//
15// WHY THIS BLOCKS THE SIGNAL INSTEAD OF INSTALLING A HANDLER, and it is not a style choice:
16// (1) A handler that RETURNS needs SA_RESTORER on x86-64 or the return frame is undefined.
17// nx_syscalls.nx's own sys_ignore_sigpipe comment records that it may skip SA_RESTORER ONLY
18// because SIG_IGN never delivers a handler frame -- a drain handler must return, so that
19// exemption does not extend to it.
20// (2) A delivered signal interrupts a blocking syscall with EINTR. A read or write cut mid-response
21// IS the truncated reply this rung exists to prevent, so the mechanism that notices TERM must
22// not be the mechanism that can corrupt the in-flight response.
23// Blocking TERM gives both properties for free: the signal can never kill this process, and it can
24// never interrupt a syscall, so the in-flight request completes on its own natural path. The signal
25// is then consumed as a FILE DESCRIPTOR READ, which is pollable -- so an idle daemon exits the
26// instant TERM arrives rather than sleeping out its budget.
27//
28// SYSCALL NUMBERING, MEASURED FROM THE ARTIFACT RATHER THAN RECALLED. TARGET_X86_64 is hard-pinned
29// undefined, so the x86 backend emits through x86ctx_rv64_to_x86_64_syscall (nx_x86_64_ctx.nx:1916)
30// whose default branch is `return num` -- an unmapped number is NOT an error, it is a DIFFERENT
31// syscall. That table was read in full: 14, 289 and 13 are NOT keys, so a raw x86-64 number passes
32// through UNCHANGED and lands on the intended call. This is the same convention the proven code in
33// this tree already uses -- SYS_SENDMSG=46 (x86 sendmsg, proven by nx_scm_rights_gate 14/14) and the
34// literal 13 in sys_ignore_sigpipe (x86 rt_sigaction, proven by nx_sigpipe_gate). The RV64 spellings
35// would be WRONG here for the same reason: rv64 135 is not a key either, so it would land on x86
36// personality, and rv64 134 would land on x86 uselib -- which is why nx_signal.nx's
37// nx_signal_action_install cannot work on this target and why this rung does not live in that file.
38// nx_ts_drain_gate proves the number empirically: a child that survives TERM can only have blocked it.
39// license_tier: ORIGINAL No hw writes (Rule 26).
40
41import "nx_syscalls.nx"
42import "nx_resmon_lib.nx" // rm_conf: the estate's line-anchored conf reader -- NOT a second one
43
44const TSD_SIGTERM: i64 = 15
45
46// Raw x86-64 numbers -- see the numbering note in the header. Named so a reader can check them
47// against the translator table rather than meet a bare integer at the call site.
48const TSD_SYS_RT_SIGPROCMASK: i64 = 14
49const TSD_SYS_SIGNALFD4: i64 = 289
50
51const TSD_SIG_BLOCK: i64 = 0
52const TSD_SFD_NONBLOCK: i64 = 0x800
53const TSD_SFD_CLOEXEC: i64 = 0x80000
54const TSD_SIGSET_BYTES: i64 = 8
55const TSD_SSI_BYTES: i64 = 128
56const TSD_SIGNALFD_NEW: i64 = 0 - 1
57
58const TSD_POLLFD_BYTES: i64 = 8
59const TSD_POLLIN: i64 = 1
60const TSD_PF_EV_OFF: i64 = 4
61const TSD_PF_RE_OFF: i64 = 6
62const TSD_NFDS_BOTH: i64 = 2
63const TSD_NFDS_ONE: i64 = 1
64const TSD_BYTE_MASK: i64 = 0xff
65// struct pollfd is { i32 fd; i16 events; i16 revents }. These are the FIELD WIDTHS, and the byte
66// lanes below are DERIVED from them rather than hand-written one line per byte: a hand-counted offset
67// sitting beside a width is a second copy of that width's shape, and the two drift silently.
68const TSD_FD_BYTES: i64 = 4
69const TSD_I16_BYTES: i64 = 2
70const TSD_BITS_PER_BYTE: i64 = 8
71
72const TSD_CONF: *u8 = "knowledge/trafficsafety_drain.conf" as *u8
73const TSD_KEY: *u8 = "drain_deadline_s" as *u8
74const TSD_CONF_ABSENT: i64 = 0 - 1
75
76// Arm verdicts -- each NAMES which step failed, so "no drain" and "no signalfd" are different
77// numbers rather than one indistinguishable -1.
78const TSD_OK: i64 = 0
79const TSD_ERR_BLOCK: i64 = 1
80const TSD_ERR_FD: i64 = 2
81
82// ts_drain_wait results.
83const TSD_W_DRAIN: i64 = 1
84const TSD_W_CONN: i64 = 0
85const TSD_W_NONE: i64 = 0 - 1
86const TSD_WAIT_BLOCK: i64 = 0 - 1
87
88// Deadline provenance. An unapplied config must be VISIBLE, so the caller is told which source it got.
89const TSD_SRC_DERIVED: i64 = 0
90const TSD_SRC_CONF: i64 = 1
91
92// THE DEADLINE, AND ITS DERIVATION -- this is not a number somebody picked.
93//
94// A drain deadline is a bound on "how long may one in-flight request still be served after TERM".
95// Every accepted connection in this tree already carries exactly that bound: the serving loops call
96// sys_set_socket_timeout(cfd, ACCEPT_TMO_S), so a single in-flight request CANNOT outlive
97// ACCEPT_TMO_S by construction. The drain deadline is therefore the SAME named constant as the
98// resource it waits on -- which is rung TS9's principle applied one rung early, and it means changing
99// ACCEPT_TMO_S changes this deadline with no second edit and no second place to forget.
100//
101// The conf exists to let an operator RAISE it for a daemon whose requests are legitimately longer,
102// never to invent the default. An absent conf, an unreadable conf, or a non-positive value all fall
103// back to the derived value and say so through out_src.
104func ts_drain_deadline_s(out_src: *i64) -> i64 {
105 *out_src = TSD_SRC_DERIVED
106 let nl: *i64 = (sys_mmap(TSD_SIGSET_BYTES * TSD_NFDS_BOTH)) as *i64
107 nl[0] = 0
108 let buf: *u8 = sys_read_file(TSD_CONF, nl)
109 if buf == (0 as *u8) { return ACCEPT_TMO_S }
110 if nl[0] <= 0 { return ACCEPT_TMO_S }
111 let v: i64 = rm_conf(buf, nl[0], TSD_KEY, TSD_CONF_ABSENT)
112 if v <= 0 { return ACCEPT_TMO_S }
113 *out_src = TSD_SRC_CONF
114 return v
115}
116
117// ARM THE DRAIN. Block TERM (so it can neither kill this process nor interrupt a syscall
118// mid-response) and return a non-blocking, close-on-exec signalfd that becomes readable when TERM
119// arrives. Returns the fd, or -1 with a named verdict.
120//
121// CALL THIS BEFORE THE LISTENER EXISTS. A daemon that arms the drain only after it starts serving
122// has a window in which TERM still kills it outright -- and that window is exactly a deploy.
123func ts_drain_on_term(out_verdict: *i64) -> i64 {
124 let mask: *u8 = sys_mmap(TSD_SIGSET_BYTES)
125 let m64: *i64 = mask as *i64
126 m64[0] = 1 << (TSD_SIGTERM - 1)
127 let br: i64 = __syscall(TSD_SYS_RT_SIGPROCMASK, TSD_SIG_BLOCK, mask as i64, 0,
128 TSD_SIGSET_BYTES, 0, 0)
129 if br < 0 { *out_verdict = TSD_ERR_BLOCK; return 0 - 1 }
130 let fd: i64 = __syscall(TSD_SYS_SIGNALFD4, TSD_SIGNALFD_NEW, mask as i64, TSD_SIGSET_BYTES,
131 TSD_SFD_NONBLOCK | TSD_SFD_CLOEXEC, 0, 0)
132 if fd < 0 { *out_verdict = TSD_ERR_FD; return 0 - 1 }
133 *out_verdict = TSD_OK
134 return fd
135}
136
137// Has TERM arrived? A non-blocking read of the signalfd: one 128-byte siginfo record means yes.
138// Consuming the record is deliberate -- the drain is a latch the caller acts on once.
139func ts_drain_requested(drain_fd: i64) -> i64 {
140 if drain_fd < 0 { return 0 }
141 let b: *u8 = sys_mmap(TSD_SSI_BYTES)
142 let n: i64 = sys_read(drain_fd, b, TSD_SSI_BYTES)
143 if n == TSD_SSI_BYTES { return 1 }
144 return 0
145}
146
147func tsd_pollfd_set(p: *u8, idx: i64, fd: i64) -> i64 {
148 let o: i64 = idx * TSD_POLLFD_BYTES
149 var k: i64 = 0
150 while k < TSD_FD_BYTES {
151 p[o + k] = ((fd >> (k * TSD_BITS_PER_BYTE)) & TSD_BYTE_MASK) as u8
152 k = k + 1
153 }
154 var e: i64 = 0
155 while e < TSD_I16_BYTES {
156 p[o + TSD_PF_EV_OFF + e] = 0 as u8
157 p[o + TSD_PF_RE_OFF + e] = 0 as u8
158 e = e + 1
159 }
160 p[o + TSD_PF_EV_OFF] = TSD_POLLIN as u8
161 return 0
162}
163
164func tsd_pollfd_ready(p: *u8, idx: i64) -> i64 {
165 let o: i64 = idx * TSD_POLLFD_BYTES
166 var e: i64 = 0
167 while e < TSD_I16_BYTES {
168 if (p[o + TSD_PF_RE_OFF + e] as i64) != 0 { return 1 }
169 e = e + 1
170 }
171 return 0
172}
173
174// WAIT for whichever comes first: a drain request, or a connection to accept.
175//
176// THE DRAIN IS TESTED FIRST, DELIBERATELY. When both are ready in the same wakeup the correct answer
177// is STOP ACCEPTING -- a loop that took the connection first would serve one more request after it
178// had already been told to stop, which is the opposite of draining. Two organs that must agree on an
179// ordering should not agree by discipline, so the ordering lives here once and every caller inherits it.
180//
181// A caller that could not arm the drain degrades to polling the listener alone rather than failing:
182// an arming failure must not turn a working daemon into a dead one.
183func ts_drain_wait(listen_fd: i64, drain_fd: i64, timeout_ms: i64) -> i64 {
184 let p: *u8 = sys_mmap(TSD_POLLFD_BYTES * TSD_NFDS_BOTH)
185 if drain_fd < 0 {
186 tsd_pollfd_set(p, 0, listen_fd)
187 let r0: i64 = sys_poll(p, TSD_NFDS_ONE, timeout_ms)
188 if r0 <= 0 { return TSD_W_NONE }
189 if tsd_pollfd_ready(p, 0) == 1 { return TSD_W_CONN }
190 return TSD_W_NONE
191 }
192 tsd_pollfd_set(p, 0, drain_fd)
193 tsd_pollfd_set(p, 1, listen_fd)
194 let r: i64 = sys_poll(p, TSD_NFDS_BOTH, timeout_ms)
195 if r <= 0 { return TSD_W_NONE }
196 if tsd_pollfd_ready(p, 0) == 1 { return TSD_W_DRAIN }
197 if tsd_pollfd_ready(p, 1) == 1 { return TSD_W_CONN }
198 return TSD_W_NONE
199}