code wiki / _hdl_build / nx_survey_daemon.nx
nx_survey_daemon.nx source
↩ module page · 217 lines · 12556 B
1// nx_survey_daemon.nx -- the DEPLOYABLE Nishi Pulse survey/poll daemon (ops shell around the gated pure
2// core nx_survey_serve). Serves 0.0.0.0:8031; CWD = nishihost on the NAS. Mounted at nishifamily.com/survey
3// via a proxy_routes.conf row (the /office precedent). Store = seg_store prefix knowledge/survey/pulse_
4// (self-healed on startup: the relate-daemon lesson -- root-owned knowledge/ may lack the subdir).
5// Admin lane reads survey_admin.key from CWD per request (FAIL-CLOSED when absent).
6// Build with --build-only; run deliberately. license_tier: ORIGINAL
7//
8// ---- TRAFFIC-SAFETY ADOPTION 2026-08-21: THIS DAEMON IS THE FIRST ADOPTER OF TS1 AND TS2. ----
9// /compare/trafficsafety measured the deficit as ADOPTION, not capability: ts_handoff_nodrop was proven
10// on 2026-08-21 and NO serving daemon called it, and rt_sigaction resolved 8 times with ZERO of them a
11// TERM drain. This file closes both for one real, low-blast-radius serving daemon.
12//
13// WHY THIS DAEMON WAS CHOSEN, from the route table rather than from taste. proxy_routes.conf carries
14// exactly ONE row for this backend (nishifamily.com /survey 8031 buffered) against 69 rows total, it owns
15// its binary outright (unlike nx_office_daemon, where one binary serves two registry rows and a deploy
16// restarts both), and it fronts no control-plane or authentication path. It is deliberately NOT sites.elf:
17// that front door is what all 69 rows cross and replacing it is rung TS3's problem, not a first adopter's.
18//
19// (1) TS1 -- KEEP THE SOCKET, REPLACE THE PROCESS. The listener now comes from ts_handoff_nodrop instead
20// of a private bind. When an owner outside this process is publishing on the rendezvous path, the
21// listening socket is INHERITED and never released, so a restart has no instant at which zero
22// processes hold the port and an arrival mid-swap queues instead of being refused. When no owner is
23// publishing it binds normally, so adopting this CANNOT make a cold start fail -- that fallback is the
24// whole reason this is safe to land on a live daemon.
25// IT ALSO REMOVES TWO LATENT HAZARDS THIS FILE CARRIED, because the shared primitive owns them:
26// the old hand-rolled bind set SO_REUSEADDR but NOT FD_CLOEXEC (the port-hostage class that took the
27// mgmt API down for every seat) and never ignored SIGPIPE (a client walking away mid-response takes
28// SIGPIPE's default action, TERMINATE). nx_http_server_listen does all three by construction.
29//
30// (2) ENVOY'S ORDERING RULE, WHICH IS THE DIRECT FIX FOR OUR OWN 8-MINUTE BLACKOUT. Every expensive step
31// -- the three request/response arenas and the seg-store warm, which reads real store files off a
32// shared array -- now happens BEFORE the listener is asked for, while the OUTGOING generation is still
33// serving. The blackout happened because a registry seed holding a process-lifetime lock ran BEFORE
34// the bind, so the incoming instance never reached accept() at all: a handoff placed after a blocking
35// call that the outgoing process gates can never run. Ordering is the entire mitigation and it is free.
36// the ordering is load-bearing, not cosmetic: moving the warm back below the acquire re-creates the
37// exact defect, because the warm's cost is set by the busiest thing on the box and not by this code.
38//
39// (3) TS2 -- COOPERATIVE DRAIN ON TERM. Armed FIRST, before the listener exists, because a daemon that
40// arms the drain only once it is serving has a window in which TERM still kills it outright, and that
41// window is exactly a deploy. TERM is BLOCKED, so it can neither kill this process nor interrupt a
42// syscall mid-response, and it is consumed as a pollable descriptor. The loop therefore waits on the
43// drain and the listener together: a drain observed at the loop top exits IMMEDIATELY (the previous
44// request has fully returned -- nothing is in flight), and a TERM that lands mid-request cannot cut
45// the response, so the client gets a complete reply and not a reset.
46// The deadline is DERIVED, never typed: see nx_ts_drain_lib and knowledge/trafficsafety_drain.conf.
47import "nx_survey_serve.nx"
48import "nx_http_server.nx" // ts_handoff_nodrop -- TS1
49import "nx_ts_drain_lib.nx" // ts_drain_on_term / ts_drain_wait / ts_drain_deadline_s -- TS2
50// sd_warm + the loop-top reap/reset below compose nx_seg_store's cache + the syscall arena:
51// ss_cache_reap frees handles superseded by ballot commits; sys_arena_mark/reset bound the
52// per-request small-allocation scratch to one slab.
53
54const SD_PORT: i64 = 0x1f5f // 8031
55const SD_ADDR_BYTES: i64 = 16
56const SD_MODE_0755: i64 = 493
57const SD_REQ_CAP: i64 = 262144
58const SD_RES_CAP: i64 = 1048576
59const SD_BACKLOG: i64 = 16
60const SD_OUT_BYTES: i64 = 64
61const SD_NUM_SCRATCH: i64 = 24
62const SD_ASCII_0: i64 = 48
63const SD_ASCII_9: i64 = 57
64const SD_B10: i64 = 10
65const SD_ARGC_PORT: i64 = 2
66const SD_ARGC_SOCK: i64 = 3
67const SD_ARGV_PORT: i64 = 1
68const SD_ARGV_SOCK: i64 = 2
69// sockaddr_in FIELD OFFSETS and widths, named so a reader can check them against the platform layout
70// instead of meeting bare indices: sin_family at 0, sin_port at 2 (big-endian). sin_addr stays all
71// zeroes, which is INADDR_ANY -- the zero-fill below is what expresses that, deliberately.
72const SD_SA_FAMILY_INET: i64 = 2
73const SD_SA_PORT_OFF: i64 = 2
74const SD_BITS_PER_BYTE: i64 = 8
75const SD_BYTE_MASK: i64 = 0xff
76// The rendezvous an owner publishes the listening socket on. argv may override it so a gate can drive a
77// REAL restart of this binary on a throwaway port without ever touching the live one -- probing a
78// single-bind service on its own port is not a diagnostic, it is a second instance.
79const SD_HANDOFF_SOCK: *u8 = "knowledge/status/survey_handoff.sock" as *u8
80
81func sd_addr(out: *u8, port: i64) -> i64 {
82 var i: i64 = 0
83 while i < SD_ADDR_BYTES { out[i] = 0 as u8; i = i + 1 }
84 out[0] = SD_SA_FAMILY_INET as u8
85 out[SD_SA_PORT_OFF] = ((port >> SD_BITS_PER_BYTE) & SD_BYTE_MASK) as u8
86 out[SD_SA_PORT_OFF + 1] = (port & SD_BYTE_MASK) as u8
87 return 0
88}
89
90func sd_putn(v: i64) -> i64 {
91 if v == 0 { sys_write(1, "0" as *u8, 1); return 0 }
92 var m: i64 = v
93 if m < 0 { sys_write(1, "-" as *u8, 1); m = 0 - m }
94 let d: *u8 = sys_mmap(SD_NUM_SCRATCH)
95 var k: i64 = 0
96 while m > 0 { d[k] = ((SD_ASCII_0 + (m - (m / SD_B10) * SD_B10)) as u8); m = m / SD_B10; k = k + 1 }
97 var j: i64 = k - 1
98 while j >= 0 { sys_write(1, ((d as i64) + j) as *u8, 1); j = j - 1 }
99 return 0
100}
101
102func main(argc: i64, argv: **u8) -> i64 {
103 let prefix: *u8 = "knowledge/survey/pulse_" as *u8
104 let base: *u8 = "/survey" as *u8
105 let keypath: *u8 = "survey_admin.key" as *u8
106 // self-heal the store dirs (idempotent; EEXIST ignored -- daemon may run as root under the guard)
107 sys_mkdir("knowledge" as *u8, SD_MODE_0755)
108 sys_mkdir("knowledge/survey" as *u8, SD_MODE_0755)
109
110 var port: i64 = SD_PORT
111 if argc >= SD_ARGC_PORT {
112 var pv: i64 = 0
113 var pi: i64 = 0
114 let pa: *u8 = argv[SD_ARGV_PORT] as *u8
115 while pa[pi] != (0 as u8) {
116 if pa[pi] >= (SD_ASCII_0 as u8) { if pa[pi] <= (SD_ASCII_9 as u8) { pv = pv * SD_B10 + ((pa[pi] as i64) - SD_ASCII_0) } }
117 pi = pi + 1
118 }
119 if pv > 0 { port = pv }
120 }
121 var sockp: *u8 = SD_HANDOFF_SOCK
122 if argc >= SD_ARGC_SOCK { sockp = argv[SD_ARGV_SOCK] as *u8 }
123
124 // ---- TS2: ARM THE DRAIN FIRST, before a listener exists to be interrupted.
125 let dv: *i64 = (sys_mmap(SD_OUT_BYTES)) as *i64
126 dv[0] = 0
127 let dfd: i64 = ts_drain_on_term(dv)
128 let dsrc: *i64 = (sys_mmap(SD_OUT_BYTES)) as *i64
129 dsrc[0] = 0
130 let deadline: i64 = ts_drain_deadline_s(dsrc)
131 sv2_p("NX-SURVEY-DAEMON drain arm verdict=" as *u8); sd_putn(dv[0])
132 sv2_p(" fd=" as *u8); sd_putn(dfd)
133 sv2_p(" deadline_s=" as *u8); sd_putn(deadline)
134 sv2_p(" deadline_src=" as *u8); sd_putn(dsrc[0])
135 sv2_p(" (0=DERIVED-from-ACCEPT_TMO_S 1=CONF)\n" as *u8)
136
137 // ---- ENVOY ORDERING RULE: ALL expensive initialisation happens HERE, BEFORE the listener is
138 // acquired, while the outgoing generation is still serving. Do not move any of it below the acquire.
139 let reqb: *u8 = sys_mmap(SD_REQ_CAP)
140 let resb: *u8 = sys_mmap(SD_RES_CAP)
141 let finb: *u8 = sys_mmap(SD_RES_CAP)
142 // WARM THE LAZY STATICS, THEN MARK (2026-08-19). The closure census (15 files, complete) found
143 // exactly 7 arena-class lazy statics, all in nx_seg_store: ssh_vo/ssh_vl (first ss_hget),
144 // ssg_sp/ssg_vo/ssg_vl (first ss_get_idx), ssc_stats + the cache tables (ssc_init). Each is
145 // exercised ONCE here so every static's storage sits BELOW the arena mark and no reset can ever
146 // recycle a live static. The probe results are deliberately ignored: an empty store answers
147 // "absent" and still allocates the statics, which is all the warm needs.
148 let wmp: *i64 = sys_mmap(SD_OUT_BYTES) as *i64
149 let wml: *i64 = sys_mmap(SD_OUT_BYTES) as *i64
150 ssc_init()
151 let wh: *i64 = ss_open_cached(prefix)
152 if (wh as i64) != 0 { ss_hget(wh, "q:n" as *u8, wmp, wml) }
153 ss_get_idx(prefix, "q:n" as *u8, wmp, wml)
154
155 // ---- TS1: ONLY NOW ask for the listening socket. Inherited when an owner is publishing on sockp,
156 // bound normally when there is none -- so this can never make a cold start fail.
157 let addr: *u8 = sys_mmap(SD_ADDR_BYTES)
158 sd_addr(addr, port)
159 let lv: *i64 = (sys_mmap(SD_OUT_BYTES)) as *i64
160 lv[0] = 0
161 let lfd: i64 = ts_handoff_nodrop(sockp, addr, SD_BACKLOG, lv)
162 if lfd < 0 {
163 sv2_p("NX-SURVEY-DAEMON listener FAILED verdict=" as *u8); sd_putn(lv[0])
164 sv2_p(" -- fail loud\n" as *u8)
165 return 1
166 }
167 sv2_p("NX-SURVEY-DAEMON serving port=" as *u8); sd_putn(port)
168 sv2_p(" store=knowledge/survey/pulse_ base=/survey listen_fd=" as *u8); sd_putn(lfd)
169 sv2_p(" (accept loop)\n" as *u8)
170 sys_arena_mark()
171
172 var go: i64 = 1
173 var served: i64 = 0
174 while go == 1 {
175 // QUIESCENT POINT: the previous request has fully returned, no seg-store handle is in use.
176 // Reap handles superseded by ballot commits (bounded handle churn), then reset the arena so
177 // per-request small scratch reuses ONE slab instead of abandoning chunks forever.
178 ss_cache_reap()
179 sys_arena_reset()
180 // TS2: nothing is in flight at this instant, so a drain seen HERE exits immediately rather than
181 // sleeping out its budget. The wait blocks until one of the two actually happens, so an idle
182 // daemon costs nothing and still notices TERM the moment it arrives.
183 let w: i64 = ts_drain_wait(lfd, dfd, TSD_WAIT_BLOCK)
184 if w == TSD_W_DRAIN {
185 // Hard backstop so even a pathological shutdown cannot outlive the declared deadline.
186 sys_alarm(deadline)
187 go = 0
188 } else {
189 if w == TSD_W_CONN {
190 let cfd: i64 = sys_accept(lfd)
191 // DoS-starvation bound (nx_dos_timeout_scan seq321): one peer declaring a body it never finishes
192 // sending would otherwise starve this accept loop forever. ACCEPT_TMO_S is the shared named bound,
193 // and it is the same constant the drain deadline is derived from -- an in-flight request cannot
194 // outlive its own socket deadline, which is exactly what makes that deadline a real bound.
195 if cfd >= 0 { sys_set_socket_timeout(cfd, ACCEPT_TMO_S) }
196 if cfd >= 0 {
197 let rn: i64 = sv2_read_req(cfd, reqb, SD_REQ_CAP)
198 if rn > 0 {
199 let on: i64 = sv2_handle(prefix, base, keypath, reqb, rn, resb, SD_RES_CAP)
200 if on > 0 {
201 // strict-HTTP/1.1: stamp Content-Length + Connection: close so the sovereign browser
202 // (behind the keep-alive buffered edge) reads the body cleanly, not to the timeout.
203 let fn: i64 = sv2_finalize(resb, on, finb, SD_RES_CAP)
204 sv2_write_all(cfd, finb, fn)
205 }
206 }
207 sys_close(cfd)
208 served = served + 1
209 }
210 } }
211 }
212 // Announce the drain so its ABSENCE is visible in normal output, not only its failure.
213 sv2_p("NX-SURVEY-DAEMON drained on TERM served=" as *u8); sd_putn(served)
214 sv2_p(" deadline_s=" as *u8); sd_putn(deadline)
215 sv2_p(" -- stopped accepting, no request cut\n" as *u8)
216 return 0
217}