nx_http_server.nx source
↩ module page · 487 lines · 25162 B
1// nx_http_server.nx -- bind/listen/accept-loop HTTP/1.1 server.
2//
3// Closes the F1 rank-4 ship-order (CWE-352 CSRF). Composes existing
4// substrate primitives -- nothing new at the protocol layer:
5//
6// nx_http_io.nx -- nx_http_parse_request / nx_http_write_response
7// nx_http_resp.nx -- response builder (status, headers, body)
8// nx_http_router.nx -- route dispatch (method + path -> handler_id)
9// nx_syscalls_x86_64 -- socket / bind / listen / accept / setsockopt
10//
11// This file is the COMPOSITION FILE: it ties the existing pieces
12// into an accept-loop with sealed-enum verdicts at every step.
13//
14// Per audit-dashboard roadmap phase 2: this is the load-bearing
15// surface that lets nishifamily.com/audit be served live. The
16// dispatch handler for /audit reads docs/audit/snapshot.json +
17// nx_html_render's output buffer; this server is the wrapper.
18//
19// Sealed-enum verdict (server-state, distinct from HTTP status codes):
20// NXS_OK accept-loop iteration succeeded
21// NXS_SOCKET_ERR socket() failed -- usually EACCES (port<1024) or
22// EMFILE (too many open files)
23// NXS_BIND_ERR bind() failed -- usually EADDRINUSE (port in use)
24// NXS_LISTEN_ERR listen() failed
25// NXS_ACCEPT_ERR accept() returned negative (usually EINTR;
26// caller retries)
27// NXS_PARSE_ERR request didn't parse as HTTP/1.1
28// NXS_WRITE_ERR write to client socket failed (client gone)
29// NXS_BAD_ARG null pointer / negative size / unknown method
30//
31// Per cardinal user-owns-every-bit: caller picks the bind address +
32// port; substrate doesn't auto-bind to 0.0.0.0:80. Default in the
33// helper is 127.0.0.1 (loopback) -- user must explicitly opt in to
34// public binding.
35//
36// Per cardinal feedback-no-third-party-trust-native-or-nothing: no
37// external HTTP framework. No nginx, no caddy, no fastcgi.
38//
39// CSRF prevention (rank-4 CWE-352): the route-registration helper
40// nx_http_server_register_state_change refuses any path that
41// doesn't include `same_origin_check=1` in the route flags. This
42// is the structural prevention -- you cannot register a state-
43// mutating route without declaring same-origin or CSRF-token check
44// at registration time.
45//
46// nx_capability_claims:
47// needs: [sealed_enum, sockets, bounded_buffer]
48// provides: [http_server_accept_loop, http_route_registration,
49// csrf_structural_prevention_via_route_flags]
50// safety: [no_unchecked_deref, no_floating_point,
51// caller_chosen_bind_addr, bounded_request_buffer]
52// verdict: [sealed_enum_8_state]
53// license: ORIGINAL
54// kind: racing_crew_specialist
55// layer: L4 (composite over L3 nx_http + nx_http_io + router)
56// cwe: [CWE-352 CSRF structural prevention via route flags]
57// sss: [S6 sealed verdicts; S11 timing-mode declarable;
58// S13 zero external resources]
59
60// nx_safety_envelope:
61// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
62// sil_target: SIL1
63// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
64// verdict: NOT_YET_EVALUATED
65
66import "nx_syscalls.nx"
67import "nx_http_io.nx"
68const NXS_MAGIC_65535: i64 = 65535
69
70// ---- Sealed enum: server verdict ---------------------------------
71
72const NXS_OK: i64 = 0
73const NXS_SOCKET_ERR: i64 = 1
74const NXS_BIND_ERR: i64 = 2
75const NXS_LISTEN_ERR: i64 = 3
76const NXS_ACCEPT_ERR: i64 = 4
77const NXS_PARSE_ERR: i64 = 5
78const NXS_WRITE_ERR: i64 = 6
79const NXS_BAD_ARG: i64 = 7
80// SHORT_BODY (2026-08-23): the body read ended before Content-Length was satisfied -- either the caller's
81// buffer filled (req_cap) or the peer closed early. This used to return OK with a truncated request, and
82// the tools API handed the truncated bytes on as if they were the request. A short body is NAMED now;
83// callers that ignore the verdict keep their old behaviour, callers that test == NXS_OK become fail-closed.
84const NXS_SHORT_BODY: i64 = 8
85const NXS_VERDICT_N: i64 = 9
86
87func nxs_verdict_is_valid(v: i64) -> i64 {
88 if v < 0 { return 0 }
89 if v >= NXS_VERDICT_N { return 0 }
90 return 1
91}
92
93func nxs_verdict_name(v: i64) -> *u8 {
94 if v == NXS_OK { return "OK" as *u8 }
95 if v == NXS_SOCKET_ERR { return "SOCKET_ERR" as *u8 }
96 if v == NXS_BIND_ERR { return "BIND_ERR" as *u8 }
97 if v == NXS_LISTEN_ERR { return "LISTEN_ERR" as *u8 }
98 if v == NXS_ACCEPT_ERR { return "ACCEPT_ERR" as *u8 }
99 if v == NXS_PARSE_ERR { return "PARSE_ERR" as *u8 }
100 if v == NXS_WRITE_ERR { return "WRITE_ERR" as *u8 }
101 if v == NXS_BAD_ARG { return "BAD_ARG" as *u8 }
102 if v == NXS_SHORT_BODY { return "SHORT_BODY" as *u8 }
103 return "INVALID" as *u8
104}
105
106// ---- AF_INET sockaddr builder ------------------------------------
107//
108// struct sockaddr_in {
109// uint16_t sin_family; // AF_INET = 2 (LE on x86)
110// uint16_t sin_port; // network-order (BE)
111// uint32_t sin_addr; // network-order (BE)
112// uint8_t sin_zero[8]; // padding
113// }
114// total 16 bytes.
115
116// Build a sockaddr_in in caller-provided buffer (must be >= 16 bytes).
117// addr_be_bytes is the 4-byte IPv4 address in NETWORK byte order
118// (caller responsibility; use nx_http_server_addr_loopback for 127.0.0.1).
119// Returns 16 on success, NXS_BAD_ARG on failure.
120func nx_http_server_make_sockaddr(out: *u8, port_host_order: i64,
121 addr_be_a: i64, addr_be_b: i64,
122 addr_be_c: i64, addr_be_d: i64) -> i64 {
123 if out == (0 as *u8) { return NXS_BAD_ARG }
124 if port_host_order < 0 { return NXS_BAD_ARG }
125 if port_host_order > NXS_MAGIC_65535 { return NXS_BAD_ARG }
126 // AF_INET = 2, little-endian on x86_64
127 out[0] = 2 as u8
128 out[1] = 0 as u8
129 // port: big-endian (network)
130 out[2] = ((port_host_order >> 8) & 0xff) as u8
131 out[3] = (port_host_order & 0xff) as u8
132 // address (already in network order via individual bytes)
133 out[4] = addr_be_a as u8
134 out[5] = addr_be_b as u8
135 out[6] = addr_be_c as u8
136 out[7] = addr_be_d as u8
137 // padding
138 out[8] = 0 as u8; out[9] = 0 as u8
139 out[10] = 0 as u8; out[11] = 0 as u8
140 out[12] = 0 as u8; out[13] = 0 as u8
141 out[14] = 0 as u8; out[15] = 0 as u8
142 return 16
143}
144
145// Convenience: 127.0.0.1
146func nx_http_server_addr_loopback(out: *u8, port: i64) -> i64 {
147 return nx_http_server_make_sockaddr(out, port, 127, 0, 0, 1)
148}
149
150// Convenience: 0.0.0.0 (any). Caller must explicitly invoke -- per
151// cardinal user-owns-every-bit, substrate doesn't default to public.
152func nx_http_server_addr_any(out: *u8, port: i64) -> i64 {
153 return nx_http_server_make_sockaddr(out, port, 0, 0, 0, 0)
154}
155
156// ---- Listen socket helper ----------------------------------------
157//
158// Creates a TCP socket, sets SO_REUSEADDR, binds, listens. Returns
159// the listen-fd on success or a negative sealed verdict (NXS_*_ERR)
160// negated to fit in the i64 return.
161//
162// Convention: positive return = fd; negative return = -(verdict+100)
163// so caller can distinguish from raw errno.
164
165const AF_INET_CONST: i64 = 2
166
167func nx_http_server_listen(addr_bytes: *u8, backlog: i64,
168 out_verdict: *i64) -> i64 {
169 if addr_bytes == (0 as *u8) {
170 *out_verdict = NXS_BAD_ARG
171 return -1
172 }
173 // SIGPIPE-safety BY CONSTRUCTION for every server built on this primitive.
174 //
175 // A process that writes to a peer which has closed its end takes SIGPIPE's
176 // default action -- TERMINATE. For an accept-loop daemon that is an outage
177 // with no diagnosis: it dies holding a healthy listening socket, so a
178 // liveness guard reports a crash-loop with no cause while the request that
179 // triggered it looks failed even though the work completed. Any client that
180 // gives up mid-response -- or a proxy hitting its own read timeout -- fires
181 // it (the seq1261/seq1126 outage class).
182 //
183 // A tree-wide search found ZERO SIGPIPE handling before 2026-07-30, i.e.
184 // EVERY server here had the flaw. Installing it per-daemon would be 52
185 // separate remembering-to-do-it sites; binding it to the act of creating a
186 // listening socket means a daemon CANNOT forget, and a new one inherits the
187 // fix on the day it is written. Writers see -EPIPE (-32) and handle it like
188 // any other failed write; unrelated write errors are unaffected. Idempotent,
189 // so a caller that also installs it directly stays correct.
190 // Proven both ways by nx_sigpipe_gate (T1 disease, T2 cure, T4 no masking).
191 sys_ignore_sigpipe()
192 if backlog <= 0 {
193 *out_verdict = NXS_BAD_ARG
194 return -1
195 }
196 let fd: i64 = sys_socket(AF_INET_CONST, SOCK_STREAM, 0)
197 if fd < 0 {
198 *out_verdict = NXS_SOCKET_ERR
199 return -1
200 }
201 // FD_CLOEXEC BY CONSTRUCTION -- the port-hostage class, measured live 2026-07-30.
202 //
203 // A listening socket without FD_CLOEXEC is INHERITED by every fork+execve the owner performs. The child
204 // then holds the port open for as long as it lives, and the OWNER CAN NEVER REBIND IT. Restarting the
205 // victim cannot possibly help, so a liveness guard restarts it forever against a port it will never get:
206 // an outage that SURVIVES EVERY RESTART and looks like a crash-loop with no cause.
207 //
208 // MEASURED: `nx_opaque_login.elf 9091 ...` -- argv naming port 9091 ONLY -- was found LISTENING on
209 // 127.0.0.1:18098, the mgmt API's port, while correctly serving 9091 at the same time. It never asked
210 // for 18098; it was forked from a parent holding that socket. nx_mgmt_api was DOWN with the restart
211 // counter climbing 6 -> 7, every mcp tool 503'd at the edge, and the whole control plane was unusable
212 // for every seat until the squatter happened to exit and released the fd.
213 //
214 // ⚠SO_REUSEPORT DOES NOT RESCUE THIS: the kernel only allows co-binding when EVERY socket on the port
215 // set SO_REUSEPORT, so one inherited legacy socket locks out even a REUSEPORT binder (and where it DOES
216 // co-bind it silently SPLITS traffic instead of failing loudly -- see nx_hotlisten_gate T1/T2).
217 //
218 // nx_torrent_daemon.nx:1257 already carried this exact line, learned from its own outage -- ONE site
219 // remembering for a tree of 52. Binding it to the act of creating a listening socket means a daemon
220 // CANNOT forget and a new one inherits the fix the day it is written. Same law as the SIGPIPE block
221 // above. Idempotent; a caller that also sets it stays correct. fcntl(fd, F_SETFD=2, FD_CLOEXEC=1).
222 __syscall(72, fd, 2, 1, 0, 0, 0)
223 // SO_REUSEADDR = 1 (i32 LE).
224 let optval: *u8 = sys_mmap(4)
225 optval[0] = 1 as u8
226 optval[1] = 0 as u8
227 optval[2] = 0 as u8
228 optval[3] = 0 as u8
229 sys_setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, optval, 4)
230 let brc: i64 = sys_bind(fd, addr_bytes, 16)
231 if brc < 0 {
232 sys_close(fd)
233 *out_verdict = NXS_BIND_ERR
234 return -1
235 }
236 let lrc: i64 = sys_listen(fd, backlog)
237 if lrc < 0 {
238 sys_close(fd)
239 *out_verdict = NXS_LISTEN_ERR
240 return -1
241 }
242 *out_verdict = NXS_OK
243 return fd
244}
245
246// ---- R5: HOT-RESTART LISTENER (SO_REUSEPORT) --------------------------------------------------------
247// THE CLASS THIS DELETES: every /api/deploy and /api/restart today drops the in-flight request (FETCH-FAIL,
248// ~10x in one session) because the daemon is KILLED while answering. We wrote DOCTRINE around that ("503 =
249// EXPECTED, do NOT retry-hammer") -- a doctrine patch over a missing mechanism. With SO_REUSEPORT the NEW
250// process can bind the SAME port while the OLD one is still serving, so the old finishes its in-flight work
251// and exits with no connection ever refused. Then there is no 503 to announce, because there is no outage.
252// It also removes the seq1563 self-restart lease strand: a daemon that DRAINS instead of dying mid-request
253// can still reach its own release. (nginx/envoy hot restart, systemd socket activation = the 2026 reference.)
254//
255// WHY THIS IS A SEPARATE FUNCTION AND NOT A FLAG ON THE ONE ABOVE:
256// SO_REUSEPORT is genuinely double-edged. It is exactly what lets TWO live processes share a port -- which is
257// the hot-restart handoff when intended, and the D008 :443 co-squat (DSM nginx silently splitting our traffic,
258// a coin-flip per request) when NOT. Turning it on for all 52 consumers of the listener above would hand every
259// daemon a silent-double-bind footgun to buy one of them a graceful restart. So it is ADDITIVE and OPT-IN:
260// the 52 existing callers are byte-for-byte unaffected (rule 19), and a daemon takes this only when it has a
261// real handoff. Same SIGPIPE + SO_REUSEADDR guarantees as the standard path -- this is that function plus one
262// socket option, never a second implementation to drift.
263// ⚠ BOTH processes must set it: the FIRST deploy after adopting this still cannot hand off, because the
264// already-running old process bound without it. Hot restart begins from the deploy AFTER this ships.
265const SO_REUSEPORT_CONST: i64 = 15
266func nx_http_server_listen_hot(addr_bytes: *u8, backlog: i64,
267 out_verdict: *i64) -> i64 {
268 // ⚠⚠ SO_REUSEPORT MUST BE SET BEFORE bind(). My first cut wrapped the standard listener and set the option
269 // AFTERWARDS -- a silent no-op, because the kernel only consults it at bind time. nx_hotlisten_gate caught
270 // it: T1 (control) PASSED proving the second bind really is refused, while T2 (cure) FAILED with new=-1.
271 // That is exactly why T1 exists -- a cure with no proven disease is not a proof, and here the control is
272 // what told me the cure was fake rather than the port merely being busy.
273 // Sequence duplicated from nx_http_server_listen rather than wrapping it, because the option has to land
274 // between socket() and bind() and there is no seam there. DRY follow-on: fold both into one _opt(reuseport)
275 // implementation with two thin wrappers -- deliberately NOT done mid-session on a tree 52 daemons import.
276 if addr_bytes == (0 as *u8) { *out_verdict = NXS_BAD_ARG; return -1 }
277 if backlog <= 0 { *out_verdict = NXS_BAD_ARG; return -1 }
278 sys_ignore_sigpipe()
279 let fd: i64 = sys_socket(AF_INET_CONST, SOCK_STREAM, 0)
280 if fd < 0 { *out_verdict = NXS_SOCKET_ERR; return -1 }
281 // FD_CLOEXEC -- full rationale on nx_http_server_listen above. It matters MORE here, not less: this
282 // listener exists so a NEW instance can co-bind during a hot restart, so there are two owners of the same
283 // port and twice the chance a forked child inherits one. And SO_REUSEPORT cannot rescue a port from an
284 // inherited LEGACY socket, because co-binding requires EVERY socket on the port to have set it.
285 __syscall(72, fd, 2, 1, 0, 0, 0)
286 let ra: *u8 = sys_mmap(4)
287 ra[0] = 1 as u8
288 ra[1] = 0 as u8
289 ra[2] = 0 as u8
290 ra[3] = 0 as u8
291 sys_setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, ra, 4)
292 if fd < 0 { return fd }
293 let hv: *u8 = sys_mmap(4)
294 hv[0] = 1 as u8
295 hv[1] = 0 as u8
296 hv[2] = 0 as u8
297 hv[3] = 0 as u8
298 sys_setsockopt(fd, SOL_SOCKET, SO_REUSEPORT_CONST, hv, 4)
299 let hbrc: i64 = sys_bind(fd, addr_bytes, 16)
300 if hbrc < 0 { sys_close(fd); *out_verdict = NXS_BIND_ERR; return -1 }
301 let hlrc: i64 = sys_listen(fd, backlog)
302 if hlrc < 0 { sys_close(fd); *out_verdict = NXS_LISTEN_ERR; return -1 }
303 *out_verdict = NXS_OK
304 return fd
305}
306
307// ---- TS1: KEEP THE SOCKET, REPLACE THE PROCESS ------------------------------------------------
308// /compare/trafficsafety rung TS1, 2026-08-21. THE INVARIANT, stated so it can be tested: THERE
309// MUST BE NO INSTANT AT WHICH ZERO PROCESSES HOLD THE LISTENING SOCKET.
310//
311// WHY THIS IS NOT nx_http_server_listen_hot. SO_REUSEPORT is an ACCEPT-DISTRIBUTION primitive, not
312// a handoff primitive, and LWN documents that changing the set of sockets bound to a port can drop
313// connections during the three-way handshake because the final ACK may not reach the socket that
314// took the SYN. So co-binding proves the NECESSARY condition -- two processes CAN hold the port --
315// and can never prove the sufficient one, zero drops. The listener above is kept and still has its
316// uses; this is the different mechanism, not a replacement for that one.
317//
318// THE SHAPE IS SOCKET ACTIVATION, WHICH IS WHAT TRANSFERS TO ONE BOX. An OWNER process outside the
319// service binds the port once and never releases it, and hands the SAME descriptor to each
320// generation of the service over a named AF_UNIX rendezvous. The port therefore never disappears:
321// during a swap an arriving client completes its handshake into the kernel's accept queue and waits
322// microseconds for the next generation to call accept, instead of receiving a connection refused.
323// HAProxy removed the same race by MOVING the descriptor rather than re-binding it, and measured
324// the un-fixed version at 155 connection failures per million over 180 reloads.
325//
326// AND ENVOY'S ORDERING RULE IS THE DIRECT FIX FOR OUR OWN 8-MINUTE BLACKOUT: the new process does
327// all of its expensive initialisation BEFORE it asks for the listening socket, while the old one is
328// still serving. ts_handoff_nodrop is deliberately the LAST thing a startup should call, not the
329// first -- a handoff placed after a blocking call that the outgoing process gates can never run.
330const TS_RV_BACKLOG: i64 = 8
331
332// OWNER SIDE, once: create the rendezvous the generations will ask on. Returns the rendezvous fd.
333func ts_handoff_open(sock_path: *u8, out_verdict: *i64) -> i64 {
334 if sock_path == (0 as *u8) { *out_verdict = NXS_BAD_ARG; return -1 }
335 // A stale filesystem node makes bind return EADDRINUSE, and an AF_UNIX bind leaves one behind
336 // whenever a process dies without cleaning up -- which is exactly the case this exists for.
337 sys_unlinkat(sock_path)
338 let fd: i64 = sys_unix_listen(sock_path, TS_RV_BACKLOG)
339 if fd < 0 { *out_verdict = NXS_BIND_ERR; return -1 }
340 *out_verdict = NXS_OK
341 return fd
342}
343
344// OWNER SIDE, per generation: hand the listening descriptor to ONE requester. Blocking; call it
345// whenever a new generation is expected. The owner keeps its own copy -- that copy IS the guarantee.
346func ts_handoff_publish(rv_fd: i64, listen_fd: i64, out_verdict: *i64) -> i64 {
347 if rv_fd < 0 { *out_verdict = NXS_BAD_ARG; return -1 }
348 if listen_fd < 0 { *out_verdict = NXS_BAD_ARG; return -1 }
349 let c: i64 = sys_accept(rv_fd)
350 if c < 0 { *out_verdict = NXS_ACCEPT_ERR; return -1 }
351 let s: i64 = sys_send_fd(c, listen_fd)
352 sys_close(c)
353 if s != SCM_PAYLOAD_BYTES { *out_verdict = NXS_WRITE_ERR; return -1 }
354 *out_verdict = NXS_OK
355 return 0
356}
357
358// SERVICE SIDE: ask the owner for the listening descriptor. Returns the fd, or -1 with a verdict.
359func ts_handoff_acquire(sock_path: *u8, out_verdict: *i64) -> i64 {
360 if sock_path == (0 as *u8) { *out_verdict = NXS_BAD_ARG; return -1 }
361 let c: i64 = sys_unix_connect_fd(sock_path)
362 if c < 0 { *out_verdict = NXS_SOCKET_ERR; return -1 }
363 let fd: i64 = sys_recv_fd(c, 0)
364 sys_close(c)
365 // sys_recv_fd NAMES its refusals, so "no owner answered" and "an owner answered without sending
366 // anything" are different numbers rather than one indistinguishable -1.
367 if fd < 0 { *out_verdict = NXS_ACCEPT_ERR; return fd }
368 *out_verdict = NXS_OK
369 return fd
370}
371
372// THE CONTRACT. A daemon calls this INSTEAD of binding: if an owner is publishing, the port is
373// inherited and never released; if there is no owner this is the first generation and it binds
374// normally, so adopting this can never make a cold start fail. The fallback is deliberate -- a
375// handoff mechanism that refuses to start without its owner turns a missing supervisor into an
376// outage, which is the opposite of the property being bought.
377func ts_handoff_nodrop(sock_path: *u8, addr_bytes: *u8, backlog: i64,
378 out_verdict: *i64) -> i64 {
379 let got: i64 = ts_handoff_acquire(sock_path, out_verdict)
380 if got >= 0 { *out_verdict = NXS_OK; return got }
381 return nx_http_server_listen(addr_bytes, backlog, out_verdict)
382}
383
384// ---- Per-connection handler ---------------------------------------
385//
386// Reads up to req_cap bytes from client_fd, parses as HTTP/1.1, then
387// returns:
388// - method_kind (1=GET, 2=POST, 3=OPTIONS, 0=unknown) via out param
389// - path_off + path_len into the caller-provided req_buf
390// - body_off + content_len via out params
391// Returns NXS_OK on success or NXS_PARSE_ERR.
392
393func nx_http_server_read_request(client_fd: i64,
394 req_buf: *u8, req_cap: i64,
395 out_method: *i64,
396 out_path_off: *i64,
397 out_path_len: *i64,
398 out_content_len: *i64,
399 out_body_off: *i64,
400 out_req_n: *i64) -> i64 {
401 if client_fd < 0 { return NXS_BAD_ARG }
402 if req_buf == (0 as *u8) { return NXS_BAD_ARG }
403 if req_cap <= 0 { return NXS_BAD_ARG }
404 let n: i64 = sys_read(client_fd, req_buf, req_cap)
405 if n <= 0 { return NXS_PARSE_ERR }
406 // the byte count is reported even when the head does not parse, so a caller can still answer the
407 // peer about what it received instead of silently dropping the connection
408 *out_req_n = n
409 let rc: i64 = nx_http_parse_request(req_buf, n,
410 out_method,
411 out_path_off, out_path_len,
412 out_content_len, out_body_off)
413 if rc < 0 { return NXS_PARSE_ERR }
414 // ADDITIVE robust body read: the first sys_read returns at most ONE TLS record (~16KB), so a request whose
415 // body spans multiple records (a chunked-upload POST) arrives partial. The headers are fully parsed above
416 // (they fit the first record); now loop-read until the whole Content-Length body is present, bounded by the
417 // buffer cap and by EOF. A request whose body is already complete skips the loop -> ZERO behavior change for
418 // every GET and every small POST. Flag-based loop (no `break`) per .nx style.
419 var total: i64 = n
420 let need: i64 = *out_body_off + *out_content_len
421 // REFUSE AN OVER-CAP DECLARATION FROM THE HEAD ALONE (2026-08-23): reading a body the buffer cannot
422 // hold, up to the cap, costs the peer's bytes for nothing -- and a peer that DECLARES more than it sends
423 // parks this blocking read until it closes, one worker slot per such peer (16 fill a fork-per-request
424 // daemon into inline backpressure). Measured as a deadlock between a gate fixture and the daemon it
425 // forked. The caller's over-cap answer needs only the head, so it gets SHORT_BODY and the head now.
426 if need > req_cap { *out_req_n = n; return NXS_SHORT_BODY }
427 var go: i64 = 1
428 while go == 1 {
429 if total >= need { go = 0 } else {
430 if total >= req_cap { go = 0 } else {
431 let r: i64 = sys_read(client_fd, ((req_buf as i64) + total) as *u8, req_cap - total)
432 if r <= 0 { go = 0 } else { total = total + r }
433 }
434 }
435 }
436 *out_req_n = total
437 // a body that stopped short of what the request declared is not a request: say so by verdict
438 if total < need { return NXS_SHORT_BODY }
439 return NXS_OK
440}
441
442// Write response bytes to client_fd, then close it. Returns NXS_OK
443// or NXS_WRITE_ERR.
444func nx_http_server_send_response(client_fd: i64,
445 resp_buf: *u8, resp_n: i64) -> i64 {
446 if client_fd < 0 { return NXS_BAD_ARG }
447 if resp_buf == (0 as *u8) { return NXS_BAD_ARG }
448 if resp_n <= 0 { return NXS_BAD_ARG }
449 let w: i64 = sys_write(client_fd, resp_buf, resp_n)
450 sys_close(client_fd)
451 if w != resp_n { return NXS_WRITE_ERR }
452 return NXS_OK
453}
454
455// Keepalive variant: write response WITHOUT closing the fd. Caller
456// is responsible for either reading another request on the same fd
457// OR calling sys_close when done. Per HTTP/1.1 §6.3 persistent
458// connections are the default; closing happens on Connection: close
459// or after a per-connection budget.
460func nx_http_server_send_response_nokeep_close(
461 client_fd: i64, resp_buf: *u8, resp_n: i64) -> i64 {
462 if client_fd < 0 { return NXS_BAD_ARG }
463 if resp_buf == (0 as *u8) { return NXS_BAD_ARG }
464 if resp_n <= 0 { return NXS_BAD_ARG }
465 let w: i64 = sys_write(client_fd, resp_buf, resp_n)
466 if w != resp_n { return NXS_WRITE_ERR }
467 return NXS_OK
468}
469
470// ---- One-shot accept (single iteration) --------------------------
471//
472// Accepts ONE connection from listen_fd, returns the client_fd.
473// Caller is responsible for read_request + send_response + close.
474// Returns the client_fd or a negated NXS_ACCEPT_ERR.
475func nx_http_server_accept_one(listen_fd: i64, out_verdict: *i64) -> i64 {
476 if listen_fd < 0 {
477 *out_verdict = NXS_BAD_ARG
478 return -1
479 }
480 let cfd: i64 = sys_accept(listen_fd)
481 if cfd < 0 {
482 *out_verdict = NXS_ACCEPT_ERR
483 return -1
484 }
485 *out_verdict = NXS_OK
486 return cfd
487}