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}