nx_https_post_lib.nx source
↩ module page · 256 lines · 15718 B
1// nx_https_post_lib.nx -- URL-LEVEL HTTPS POST WITH A BODY. The primitive that was missing.
2//
3// WHY THIS EXISTS (operator standing order 2026-09-03: "dont build python ... build nishi and nishi estate
4// should have this capability and if it doesnt lets build from the first byte up these capabilities").
5// Replacing nx_content_put_client.py needs one thing the estate did not have: POST a body to a URL.
6// What existed was a trap and a half-primitive --
7// nx_https_post_json (nx_https_client.nx) took a URL and a body, issued a **GET**, discarded the body,
8// and returned OK. Corrected to REFUSE by name on 2026-09-03.
9// nx_https_post_complete_xhdr genuinely builds a POST, but takes an ESTABLISHED
10// *Tls13ClientSession and fd -- not a URL. No caller holding
11// only a URL could reach it.
12// The gap was never the POST itself. It was the connect/handshake glue between a URL and that function.
13//
14// IT COMPOSES, IT DOES NOT DUPLICATE. hf_open (nx_https_fetch_lib.nx) already resolves the URL, makes a
15// bounded connect and runs the TLS13 handshake, handing back session/fd/path/host. This file calls it and
16// swaps only the tail: nx_https_post_complete_xhdr where hf_fetch_once calls nx_https_get_complete.
17// There is still exactly ONE connect path in the estate.
18//
19// IT IS A SIBLING RATHER THAN AN EDIT TO nx_https_fetch_lib.nx, measured: that lib has 5 importers and
20// nx_https_post_complete.nx has 5 more. Adding an import to a lib five files depend on, to serve a
21// function none of them call, is blast radius bought for nothing -- the same reasoning that put the
22// RFC 6455 client half beside nx_websocket.nx rather than inside it.
23//
24// IT DOES NOT FOLLOW REDIRECTS, AND THAT IS THE POINT, NOT AN OMISSION.
25// A 301/302 on a POST is historically rewritten by clients into a GET: the method silently changes and
26// the body is dropped. That is EXACTLY the defect this file exists to repair, so following a redirect
27// here would reintroduce it one layer up and call it a feature. hp_should_follow() returns 0 for every
28// status and says why; the caller sees the status and decides. RFC 7231 permits 307/308 to preserve the
29// method, and honouring those is a deliberate future decision with its own gate -- not a default.
30//
31// A POST IS NOT IDEMPOTENT, SO THERE IS NO RETRY HELPER HERE ON PURPOSE. A blind retry of a POST that
32// may have landed double-applies. Callers that need safety must carry their own idempotency key -- which
33// is precisely what nx_content_put's per-chunk sha CAS already does.
34//
35// PROVEN 9/9 GREEN 2026-09-03 by nx_https_post_lib_gate against the PURE request builder: method is POST,
36// body travels verbatim, Content-Length equals body_len, and the GET builder is carried as the control
37// that makes the method claim mean something.
38//
39// exit contract: returns response bytes written to `out`, or a negative HF_ERR_* from the shared set.
40// license_tier: ORIGINAL No hw writes (Rule 26). LIB (no main).
41import "nx_syscalls.nx"
42import "nx_https_fetch_lib.nx"
43import "nx_https_post_complete.nx"
44import "nx_jobfollow_parse.nx" // anchored job-id parse + readiness test: the LEAF that breaks the cycle
45
46// Conventional read capability for following a job pointer. ITS ABSENCE IS THE OFF SWITCH: when this
47// file cannot be read, hp_post_json_follow announces and returns the RAW reply, so every existing
48// caller is byte-identical to today. Activation is creating one file, which makes the change
49// reviewable and reversible without rebuilding a single organ.
50const HP_JOBCAP_PATH: *u8 = "knowledge/status/jobfollow_read.cap"
51// 40 x 3000ms = 120s, matched to the content clients so the two lanes cannot disagree about how long
52// the job lane is allowed to take.
53const HP_JOB_POLLS: i64 = 40
54const HP_JOB_POLL_MS: i64 = 3000
55const HP_JOB_PATHCAP: i64 = 256
56const HP_JOB_REQCAP: i64 = 4096
57
58// Every status is refused for following. The value is returned rather than hardcoded at each call site
59// so a future 307/308 decision has exactly one place to be made, gated, and argued about.
60func hp_should_follow(status: i64) -> i64 {
61 return 0
62}
63
64// POST `body` to `url`. content_type/body are required; xhdr may be empty (pass 0 as *u8, 0).
65// cip/cport are the connect-host override the fetch lib already understands (0,0 = resolve normally).
66func hp_post_once(store_i: i64, url: *u8, cip: i64, cport: i64,
67 ctype: *u8, ctype_len: i64,
68 body: *u8, body_len: i64,
69 xhdr: *u8, xhdr_len: i64,
70 out: *u8, cap: i64) -> i64 {
71 let box: *i64 = sys_mmap(64) as *i64
72 let o: i64 = hf_open(store_i, url, cip, cport, box)
73 if o != 1 { return o }
74 let session: *Tls13ClientSession = box[0] as *Tls13ClientSession
75 let fd: i64 = box[1]
76 // box[2]/box[3] = path ptr/len, box[4]/box[5] = host ptr/len -- the same slots hf_fetch_once reads.
77 let n: i64 = nx_https_post_complete_xhdr(session, fd,
78 box[2] as *u8, box[3],
79 box[4] as *u8, box[5],
80 ctype, ctype_len,
81 body, body_len,
82 0 as *u8, 0,
83 xhdr, xhdr_len,
84 out, cap)
85 sys_close(fd)
86 if n < 0 { return HF_ERR_HTTP }
87 return n
88}
89
90// application/json convenience. It is a THIN wrapper that passes the body through -- the name promises a
91// JSON POST and the code performs one. Named deliberately after the function that used to promise this
92// and issue a GET instead.
93// _nofollow IS THE EXPLICIT NAME FOR "GIVE ME THE REPLY THE EDGE ACTUALLY SENT". Some callers genuinely
94// want the JOB-STARTED pointer -- a launcher that returns immediately is async ON PURPOSE, not broken --
95// so that behaviour keeps a name of its own instead of becoming an accident of which wrapper was called.
96func hp_post_json_nofollow(store_i: i64, url: *u8, cip: i64, cport: i64,
97 body: *u8, body_len: i64,
98 out: *u8, cap: i64) -> i64 {
99 let ct: *u8 = "application/json" as *u8
100 var ctl: i64 = 0
101 while ct[ctl] != (0 as u8) { ctl = ctl + 1 }
102 // A CLIENT THAT CANNOT DECODE MUST NOT ADVERTISE (2026-09-06). The POST completer hands the reply back
103 // verbatim -- it has no gzip stage -- while the request builder derives Accept-Encoding from every codec
104 // the estate compiles, so on the public edge (nishifamily.com:443 gzips whatever is accepted) every JSON
105 // receipt came back as gzip bytes and nx_content_put_client read no transfer id. A JSON receipt gains
106 // nothing from compression; ask for identity until the POST path decodes what it advertises.
107 let ae: *u8 = "Accept-Encoding: identity\r\n" as *u8
108 var ael: i64 = 0
109 while ae[ael] != (0 as u8) { ael = ael + 1 }
110 return hp_post_once(store_i, url, cip, cport, ct, ctl, body, body_len, ae, ael, out, cap)
111}
112
113// ---- THE ONE JOB-POINTER FOLLOWER ------------------------------------------------------------------
114// Given a reply ALREADY in `out` (length n): if the edge answered `JOB-STARTED id=<j>`, poll
115// _jobs/job_<j>.out and return THAT as the receipt. Returns n untouched when the reply is a real
116// receipt, so it is a no-op on the common path.
117//
118// IT LIVES HERE, NOT IN nx_jobfollow_lib, TO BREAK AN IMPORT CYCLE WITHOUT DUPLICATING A RULER. The
119// follower must POST (to read the artifact) and nx_jobfollow_lib already imports this file to get that,
120// so putting a follow in the lib AND in the post path would be two implementations of one invariant.
121// Implementation here, pure parsing in the leaf nx_jobfollow_parse, jf_post_follow delegates: one
122// job-id parser and one poll loop in the estate.
123//
124// RECURSION IS IMPOSSIBLE BY CONSTRUCTION -- the artifact read goes through _nofollow, so a poll can
125// never itself try to follow a pointer.
126func hp_follow_job(store_i: i64, url: *u8, out: *u8, outcap: i64, n: i64,
127 readcap: *u8, readcaplen: i64, polls: i64, sleep_ms: i64) -> i64 {
128 if n <= 0 { return n }
129 let jid: i64 = jf_job_id(out, n)
130 if jid < 0 { return n }
131 if readcaplen <= 0 {
132 // ANNOUNCE AND RETURN THE RAW REPLY, never a silent zero. The caller may be a launcher that
133 // wanted the pointer; turning that into a failure would break working organs to fix a defect
134 // they do not have.
135 jf_eo("HP-UNFOLLOWED: the edge promoted this call to the job lane and no read capability was resolvable, so the RAW reply is returned. The id in it is the JOB id, NOT the organ's -- do not parse it as a receipt.\n" as *u8)
136 return n
137 }
138 if jf_json_safe(readcap, readcaplen) == 0 {
139 jf_eo("HP-REFUSED: read capability carries a byte needing JSON escaping\n" as *u8)
140 return n
141 }
142 let path: *u8 = sys_mmap(HP_JOB_PATHCAP)
143 var pp: i64 = jf_append(path, 0, "_jobs/job_" as *u8)
144 pp = jf_append_num(path, pp, jid)
145 pp = jf_append(path, pp, ".out" as *u8)
146 path[pp] = 0 as u8
147
148 let rq: *u8 = sys_mmap(HP_JOB_REQCAP)
149 var o: i64 = jf_append(rq, 0, "{\x22jsonrpc\x22:\x222.0\x22,\x22id\x22:1,\x22method\x22:\x22tools/call\x22,\x22params\x22:{\x22name\x22:\x22nx_fs\x22,\x22arguments\x22:{\x22argv\x22:[\x22read\x22,\x22" as *u8)
150 o = jf_append(rq, o, path)
151 o = jf_append(rq, o, "\x22,\x22400000\x22],\x22_cap\x22:\x22" as *u8)
152 var k: i64 = 0
153 while k < readcaplen { rq[o] = readcap[k]; o = o + 1; k = k + 1 }
154 o = jf_append(rq, o, "\x22}}}" as *u8)
155
156 // THE POLL LADDER (2026-09-06): the budget is polls x sleep_ms of WALL TIME, exactly what the flat loop spent at
157 // worst; inside it the first wait is hp_ladder_first(sleep_ms) and every miss doubles it up to sleep_ms. MEASURED
158 // before this: a chunk post whose artifact was ready within ~100 ms still paid a flat 3,000 ms wait, so a
159 // 122-chunk push spent 8.6 s per chunk on polling granularity while the door and the array sat idle. A slow
160 // backend pays exactly what it paid before (the ladder reaches sleep_ms after four doublings).
161 let budget_ms: i64 = polls * sleep_ms
162 let t0: i64 = sys_now_ms()
163 var wait: i64 = hp_ladder_first(sleep_ms)
164 var go: i64 = 1
165 while go == 1 {
166 sys_sleep_ms(wait)
167 let m: i64 = hp_post_json_nofollow(store_i, url, 0, 0, rq, o, out, outcap)
168 let st: i64 = jf_artifact_ready(out, m)
169 if st < 0 {
170 jf_eo("HP-DENIED: the read capability does not grant nx_fs, so the job artifact is unreachable\n" as *u8)
171 return 0
172 }
173 if st == 1 { return m }
174 wait = hp_ladder_next(wait, sleep_ms)
175 if sys_now_ms() - t0 >= budget_ms { go = 0 }
176 }
177 jf_eo("HP-PENDING: the job has not written its artifact within the poll budget. The work was ACCEPTED and is STILL RUNNING -- do NOT re-post the request.\n" as *u8)
178 return 0
179}
180// the ladder, as two pure functions so a gate can hold them to their arithmetic
181const HP_LADDER_DIV: i64 = 16 // first wait = sleep_ms / 16 (187 ms at the 3,000 ms default: four doublings reach the cap)
182const HP_LADDER_MIN_MS: i64 = 50 // never poll faster than this, whatever sleep_ms a caller passes
183func hp_ladder_first(sleep_ms: i64) -> i64 {
184 var w: i64 = sleep_ms / HP_LADDER_DIV
185 if w < HP_LADDER_MIN_MS { w = HP_LADDER_MIN_MS }
186 if w > sleep_ms { w = sleep_ms }
187 return w
188}
189func hp_ladder_next(wait: i64, sleep_ms: i64) -> i64 {
190 var w: i64 = wait * 2
191 if w > sleep_ms { w = sleep_ms }
192 return w
193}
194
195// Post, then follow a job pointer IF a read capability is resolvable from the conventional path.
196//
197// DELIBERATELY NOT THE DEFAULT FOR hp_post_json, AND THAT IS A DECISION RATHER THAN AN OMISSION.
198// Following turns an async call synchronous. Silently making 81 existing callers wait for a job would
199// be changing behaviour nobody asked for in order to fix a defect most of them do not have -- and a
200// launcher returning a pointer on purpose would become a caller that blocks. The mechanism ships and is
201// proven; ACTIVATION is one file, and until it exists every caller is byte-identical to today.
202// ---- THE ONE KEYED RE-ISSUE (dataio DI4, client half, 2026-09-05) -------------------------------------------
203// POST; if the edge answers Outcome Unknown (503 read-after-accept) or the transport answers INFLIGHT, and the
204// request body carries `_idem`, wait one poll interval and post the IDENTICAL request again -- safe by
205// construction, because the tools/call transport replays the recorded outcome (`NX-IDEM REPLAY ... job=<id>`)
206// or applies once. Bounded by the caller's poll budget, announced on every re-post, and NEVER taken for an
207// unkeyed request (its 503 comes back as the reply). It lives here so jf_post_follow and hp_post_json_follow
208// share one loop: two copies of "when is a re-post safe" is the duplicate-ruler defect on the one question
209// where being wrong double-applies a write.
210func hp_post_json_reissue(store_i: i64, url: *u8, cip: i64, cport: i64,
211 body: *u8, body_len: i64,
212 out: *u8, cap: i64, polls: i64, sleep_ms: i64) -> i64 {
213 var n: i64 = hp_post_json_nofollow(store_i, url, cip, cport, body, body_len, out, cap)
214 // the same ladder as the follow loop: a replay is a lookup, so the first re-post comes quickly and a backend
215 // that is genuinely still running answers IN-FLIGHT (which jf_should_reissue keeps re-asking about) while the
216 // wait grows toward sleep_ms; the wall budget stays polls x sleep_ms
217 let budget_ms: i64 = polls * sleep_ms
218 let t0: i64 = sys_now_ms()
219 var wait: i64 = hp_ladder_first(sleep_ms)
220 var go: i64 = 1
221 while go == 1 {
222 if jf_should_reissue(out, n, body, body_len) == 1 {
223 jf_eo("JF-REISSUE: the edge accepted the call and lost the reply (or its first execution is still running); the request carries _idem, so it is re-posted -- the transport replays the recorded outcome, never a second execution\n" as *u8)
224 sys_sleep_ms(wait)
225 n = hp_post_json_nofollow(store_i, url, cip, cport, body, body_len, out, cap)
226 wait = hp_ladder_next(wait, sleep_ms)
227 if sys_now_ms() - t0 >= budget_ms { go = 0 }
228 } else { go = 0 }
229 }
230 return n
231}
232
233func hp_post_json_follow(store_i: i64, url: *u8, cip: i64, cport: i64,
234 body: *u8, body_len: i64,
235 out: *u8, cap: i64) -> i64 {
236 let n: i64 = hp_post_json_reissue(store_i, url, cip, cport, body, body_len, out, cap, HP_JOB_POLLS, HP_JOB_POLL_MS)
237 if n <= 0 { return n }
238 if jf_job_id(out, n) < 0 { return n }
239 let capbox: *i64 = sys_mmap(16) as *i64
240 let rc: *u8 = sys_read_file(HP_JOBCAP_PATH, capbox)
241 if (rc as i64) == 0 { return hp_follow_job(store_i, url, out, cap, n, 0 as *u8, 0, HP_JOB_POLLS, HP_JOB_POLL_MS) }
242 var rl: i64 = capbox[0]
243 while rl > 0 {
244 let c: i64 = rc[rl - 1] as i64
245 if c <= 32 { rl = rl - 1 } else { rl = 0 - rl }
246 }
247 if rl < 0 { rl = 0 - rl }
248 return hp_follow_job(store_i, url, out, cap, n, rc, rl, HP_JOB_POLLS, HP_JOB_POLL_MS)
249}
250
251// The historical name, kept so that not one existing caller changes behaviour. See hp_post_json_follow.
252func hp_post_json(store_i: i64, url: *u8, cip: i64, cport: i64,
253 body: *u8, body_len: i64,
254 out: *u8, cap: i64) -> i64 {
255 return hp_post_json_nofollow(store_i, url, cip, cport, body, body_len, out, cap)
256}