diff --git a/CLAUDE.md b/CLAUDE.md index e80cf4a..4870714 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,48 @@ Then ask fox about our mission. - Every implementation (Python, C, GNU asm) carries a dedicated architecture diagram - When explaining architecture, draft or reference a dot diagram first +## Web styling — CSS Grid only, never flexbox + +Every page in `www/` and `wasm/` (homepage, whitepaper, playground, +REPL, bend demo, 404) lays out multi-child regions with **CSS Grid**. +Flexbox is **banned** as a layout primitive. One layout language across +every page; no mode-switching in our head while we read or edit. + +**Forbidden** anywhere in our CSS (source or generated): + +- `display: flex`, `display: inline-flex` +- `flex-direction`, `flex-wrap`, `flex-flow` +- `flex-grow`, `flex-shrink`, `flex-basis`, shorthand `flex:` +- `order` (use grid-area / source order instead) + +**Allowed** (works for grid too — keep on grid containers only): + +- `gap`, `row-gap`, `column-gap` +- `align-items`, `justify-items`, `place-items` +- `align-content`, `justify-content`, `place-content` +- `align-self`, `justify-self`, `place-self` + +**Patterns that look like they need flex but don't:** + +- Horizontal toolbar → `display: grid; grid-auto-flow: column; gap: …` +- Tab row → same as above; sticky positioning composes fine +- Centered single child → `display: grid; place-items: center` +- Sidebar + main → `display: grid; grid-template-columns: auto 1fr` +- Wrapping chip cloud → `display: grid; grid-template-columns: repeat(auto-fit, minmax(N, max-content))` + +Every source CSS file under `www/` and `wasm/{app,repl,dist,dist-repl}/` +opens with the banner `/* No flexbox. Every multi-child layout uses +CSS Grid. */`. Keep that banner intact when editing; add it when +introducing a new stylesheet. + +**Audit before commit** when CSS changed: + +```bash +grep -rn 'display:[[:space:]]*\(inline-\)\?flex\|flex-direction\|flex-wrap\|flex-grow\|flex-shrink\|flex-basis' www/ wasm/ && exit 1 || echo "grid-only OK" +``` + +A hit fails our audit. Convert to grid before committing. + ## Implementations | Impl | Path | Build | Test | REPL | @@ -48,12 +90,18 @@ Then ask fox about our mission. `examples/cuda-fanout/` ships `(bend ...)` — runtime decides per call whether to evaluate locally or ship to a CUDA worker over our wire -protocol. Two wire modes: +protocol. **Each worker listens on two ports out of the box:** -- **S-expression mode** (text) — for small payloads. Slow above ~1k - inputs because parser cost dominates. -- **Binary mode** (magic `BSHK` + raw bytes) — for huge payloads. 150x - faster than S-exp at 1M inputs; bends past host hashlib by 12x. +- **8320 — wire-TCP** (length-prefixed S-expressions + binary magic + `BSHK`/`BCGB`/`BSCP`/`BSRT`/`BSB3` blobs). The native path: used + by `bend.lsp`, asm clients, and anything that can open a raw + socket. Fastest. Binary mode is 150× faster than S-exp at 1M inputs. +- **8321 — HTTP/1.1 + CORS** (POST body is the S-expression, response + body is the result text). The browser path: lets a tab on + `https://lumbda.com/playground/` POST to `http://localhost:8321/` + via the browser's localhost-exception (no TLS required, no proxy + needed). Same `handle-request` dispatcher fires on both ports, so + every op-head ships once and lights up on both transports. Workers run on any tier (`make gpu-worker LUMBDA={c,python,asm}`). C tier ~9x faster than Python on small calls; binary mode equalizes @@ -67,6 +115,19 @@ to dispatch real-scale candidate scoring to a GPU worker. The Phase B 1-8 secp256k1 arithmetic landed on the foxhop side this session, so the substrate has every piece it needs. +**Phase 2 (deferred until auth lands):** server-side factory ops +that take an `.lsp` recipe via HTTP, compile a `.bin` on the worker +filesystem, then bend it. Killer use case: upload a foxhop champion +ECDSA circuit recipe from the playground/REPL and get the result +back from a GPU cluster. Blocked on authentication — today a worker +without auth would let any caller occupy our GPU. + +**Run your own bend** (the decentralized story we encourage): any +user runs `make gpu-worker LUMBDA=python` on their machine and +pastes `http://localhost:8321/` into the playground bend field. +CPU works (kernels fall back to host execution); GPU faster. No +fox-owned infra required. + ## Test Suites - Python unit/integration: `tests.py` (571 tests) diff --git a/examples/cuda-fanout/Makefile b/examples/cuda-fanout/Makefile index 3a52d7d..ee46232 100644 --- a/examples/cuda-fanout/Makefile +++ b/examples/cuda-fanout/Makefile @@ -94,5 +94,11 @@ test: shake256-fanout test_roundtrip.py bench: shake256-fanout bench.py python3 bench.py ./shake256-fanout +# Dual-port smoke: gpu-worker.lsp boots on test wire+http port pair, +# curl POSTs (ping) + OPTIONS preflight, asserts response + CORS. +# No CUDA deps; runs anywhere lumbda runs. +smoke-bend-http: gpu-worker.lsp http-listener.lsp smoke-bend-http.sh + ./smoke-bend-http.sh + clean: rm -f shake256-fanout /tmp/cf-in.portal /tmp/cf-out.portal diff --git a/examples/cuda-fanout/README.md b/examples/cuda-fanout/README.md index 2535654..bea128b 100644 --- a/examples/cuda-fanout/README.md +++ b/examples/cuda-fanout/README.md @@ -90,6 +90,7 @@ cd examples/cuda-fanout python3 -u ../../lumbda.py /tmp/launch-worker.lsp # → gpu-worker: ready cuda-shake-fanout ← ./shake256-fanout # → gpu-worker listening on port 8320 (BEND mnemonic — see below) + # → gpu-worker HTTP listening on port 8321 (browser path — see below) # In another shell, drive via bend!: python3 ../../lumbda.py smoke-bend.lsp @@ -176,9 +177,45 @@ rest of the repo. make all # compile via nvcc make test # round-trip vs hashlib.shake_256 make bench # device vs host timing grid +make smoke-bend-http # HTTP/8321 path: ping/CORS, no CUDA python3 bench_daemon.py ./shake256-fanout # daemon vs per-spawn ``` +## HTTP path — browser callers (port 8321) + +`gpu-worker.lsp` listens on **two** ports out of the box: + +- `8320` — wire-TCP (length-prefixed S-exp + binary `BSHK`/`BCGB`/ + `BSCP`/`BSRT`/`BSB3` modes). Used by native callers (`bend.lsp`, + asm clients). +- `8321` — HTTP/1.1 + CORS (POST body is the S-expression, response + body is the result text). Used by browser callers — the + `https://lumbda.com/playground/` bend-url field POSTs here via + XHR. Browsers can't open raw TCP, but the browser localhost + exception permits HTTP from an HTTPS origin without TLS. + +Same `handle-request` dispatcher fires on both ports. Adding a new +op-head (e.g. a server-side factory recipe in Phase 2) exposes it on +both transports automatically. + +```bash +# Smoke test — boots the worker on test ports, asserts ping + CORS. +make smoke-bend-http + +# Hit the HTTP port directly: +curl -sS -X POST -H 'Content-Type: text/plain' \ + --data-binary '(ping)' http://127.0.0.1:8321/ +# → (ok pong) +``` + +Binary modes (`BSHK` etc.) are NOT exposed over HTTP — they exist +for native callers who already have the binary cached locally. +Browser callers either use S-expression op-heads today (`ping`, +`echo`, `cuda-shake-fanout` with inline inputs, `cuda-sim-ops-bin` +with a path the worker can read) or, once Phase 2 lands (gated on +auth), upload an `.lsp` recipe that the server compiles into a +`.bin` before bending it. + ## Measured performance — where the GPU actually wins (and loses) **Setup:** RTX 3090 (10,496 CUDA cores, sm_86) + i9-12900K host, diff --git a/examples/cuda-fanout/gpu-worker.lsp b/examples/cuda-fanout/gpu-worker.lsp index d65c53a..797cd31 100644 --- a/examples/cuda-fanout/gpu-worker.lsp +++ b/examples/cuda-fanout/gpu-worker.lsp @@ -29,8 +29,15 @@ ;;; Requires the cuda binaries on disk; paths below. (load "wire.lsp") +(load "http-listener.lsp") (define *worker-port* 8320) +;; HTTP listener port — defaults to wire-port + 1 so the same worker +;; serves both the native (raw wire frames) and browser (HTTP POST) +;; entry points. Browser callers (e.g. www/playground bend-url field) +;; can't open raw TCP, only HTTP/WebSocket, so the HTTP path closes +;; the on-ramp gap. Same handle-request dispatcher fires for both. +(define *worker-http-port* 8321) (define *binary-shake-fanout* ;; Override via env or per host. "./shake256-fanout") @@ -82,6 +89,16 @@ (or (string->number (car (cdr rest))) *worker-port*)) (else (loop (cdr rest)))))) +;; HTTP port defaults to wire-port + 1. Explicit override via --http-port. +(define (parse-http-port-arg args default) + (let loop ((rest args)) + (cond + ((null? rest) default) + ((null? (cdr rest)) default) + ((and (string? (car rest)) (string=? (car rest) "--http-port")) + (or (string->number (car (cdr rest))) default)) + (else (loop (cdr rest)))))) + (define *daemons* '()) ; alist (op-name . (stdin-port . stdout-port)) ;;; -- daemon pool (per-tier stubs marked) ----------------------- @@ -707,24 +724,31 @@ ;; record-cell-vram! in child still updates running max if larger seen. (define *vram-per-cell-max-mib* 2500) +;; Fail-open on hosts without nvidia-smi (CPU-only users running their +;; own bend from a laptop). Python tier's spawn-process-stdio raises +;; FileNotFoundError on a missing binary instead of returning #f, which +;; would crash the worker; check file-exists? before the spawn. (define (vram-used-mib) ;; nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits ;; returns one integer line per GPU; sum if multi-GPU host. - (let* ((pair (spawn-process-stdio - "/usr/bin/nvidia-smi" - '("--query-gpu=memory.used" - "--format=csv,noheader,nounits")))) - (cond - ((eq? pair #f) 0) ;; fail-open: assume 0 if nvidia-smi missing - (else - (close-port (car pair)) ;; close stdin to child - (let loop ((sum 0)) - (let ((line (read-line (cdr pair)))) - (cond - ((eof-object? line) (close-port (cdr pair)) sum) - (else - (let ((n (string->number (string-trim line)))) - (loop (+ sum (cond (n n) (else 0))))))))))))) + (cond + ((not (file-exists? "/usr/bin/nvidia-smi")) 0) + (else + (let* ((pair (spawn-process-stdio + "/usr/bin/nvidia-smi" + '("--query-gpu=memory.used" + "--format=csv,noheader,nounits")))) + (cond + ((eq? pair #f) 0) ;; fail-open: assume 0 if nvidia-smi missing + (else + (close-port (car pair)) ;; close stdin to child + (let loop ((sum 0)) + (let ((line (read-line (cdr pair)))) + (cond + ((eof-object? line) (close-port (cdr pair)) sum) + (else + (let ((n (string->number (string-trim line)))) + (loop (+ sum (cond (n n) (else 0))))))))))))))) (define (string-trim s) ;; trim leading + trailing whitespace. @@ -858,6 +882,78 @@ (wire-send client resp) (tcp-close client)))))) +;;; -- HTTP path ------------------------------------------------- +;;; +;;; Mirrors the TCP path's fork-per-accept model so concurrent HTTP +;;; clients run isolated children with the same VRAM admission +;;; gating as wire-frame clients. handle-http-client decodes the +;;; HTTP request, dispatches the body S-expression through the +;;; SAME handle-request dispatcher used by the TCP else-branch, and +;;; writes an HTTP/1.1 response with CORS headers so the browser +;;; playground (https://lumbda.com/playground/) can POST directly +;;; to a worker running on the user's own machine via +;;; http://localhost:8321/ (browsers permit localhost without TLS). +;;; +;;; Binary-mode forms (BSHK/BCGB/BSCP/BSRT/BSB3) are NOT exposed +;;; over HTTP — those exist for native callers who already have +;;; the binary cached locally. HTTP callers either use S-expression +;;; forms (echo / ping / cuda-shake-fanout / cuda-sim-ops-bin with +;;; an on-disk path) or, once Phase 2 lands, server-side factory +;;; ops that compile recipes into binaries before bending them. + +(define (handle-http-client client) + (let ((req (read-http-request client))) + (cond + ((eq? req #f) + (tcp-close client)) + (else + (let ((method (car req)) + (body (car (cdr (cdr req))))) + (cond + ((string=? method "OPTIONS") + (write-http-options-response client) + (tcp-close client)) + ((string=? method "POST") + (let* ((sexp (read-from-string body)) + (resp (handle-request sexp)) + (resp-text (write-to-string resp))) + (write-http-response client 200 resp-text + "text/plain; charset=utf-8") + (tcp-close client))) + (else + (write-http-response client 405 + "Only POST and OPTIONS are supported.\n" + "text/plain; charset=utf-8") + (tcp-close client)))))))) + +;; NOTE — fork-self discrimination: +;; Python tier's (eq? 0 #f) returns #t (== conflates int 0 with bool +;; False). C tier (eq? identity) returns #f correctly. To stay portable +;; we use (number? pid) to distinguish "fork failed (#f)" from "child +;; (numeric 0)". Don't switch to (eq? pid 0) here — works on C/asm but +;; runs the failed-fork branch in every Python-tier child. +(define (http-run-loop server) + (waitpid-nonblock) + (wait-admit *vram-per-cell-max-mib*) + (let ((client (tcp-accept server))) + (cond + ((eq? client #f) (http-run-loop server)) + (else + (let ((pid (fork-self))) + (cond + ((not (number? pid)) + ;; fork failed — fall back to serial handle + (handle-http-client client) + (http-run-loop server)) + ((= pid 0) + ;; child: handle one HTTP client, then exit + (handle-http-client client) + (exit 0)) + (else + ;; parent: close our copy of client fd, loop to accept + (tcp-close client) + (http-run-loop server)))))))) + ;; Optional registration — only spawn the daemon when its binary is ;; reachable. Lets a worker host serve a subset of forms without ;; failing to start because some bend form's daemon isn't installed. @@ -878,9 +974,19 @@ ;; lands (see plans/form-A-day4-progress.md). (define *secp-daemon-extra-args* '("--window-w" "4")) +;; Dual-port main — wire-TCP on *worker-port* (default 8320), HTTP on +;; *worker-http-port* (default port+1 = 8321). One fork at startup +;; splits the process: child runs http-run-loop, parent runs the +;; existing run-loop. Same handle-request dispatcher fires for both. +;; +;; If the HTTP listener fails (port in use, etc.), the worker falls +;; back to wire-only — the wire path stays the source-of-truth and +;; the HTTP path is a convenience for browser callers. (define (main) - (let ((port (parse-port-arg *argv*))) + (let* ((port (parse-port-arg *argv*)) + (http-port (parse-http-port-arg *argv* (+ port 1)))) (set! *worker-port* port) + (set! *worker-http-port* http-port) (maybe-register-daemon! 'cuda-shake-fanout *binary-shake-fanout*) (maybe-register-daemon! 'cuda-bignum-cgbn *binary-cgbn-batch*) (maybe-register-daemon! 'cuda-secp256k1-batched-mul @@ -888,15 +994,42 @@ *secp-daemon-extra-args*) (maybe-register-daemon! 'cuda-radix-sort *binary-radix-sort*) (maybe-register-daemon! 'cuda-blake3-tree *binary-blake3-fanout*) - (let ((server (tcp-listen port))) + (let ((wire-server (tcp-listen port)) + (http-server (tcp-listen http-port))) (cond - ((eq? server #f) - (display ";;; ERROR -- tcp-listen failed on port ") + ((eq? wire-server #f) + (display ";;; ERROR -- tcp-listen failed on wire port ") (display port) (newline)) - (else + ((eq? http-server #f) + (display ";;; WARN -- tcp-listen failed on http port ") + (display http-port) (display "; running wire-only") (newline) (display "gpu-worker listening on port ") (display port) (newline) - (run-loop server)))))) + (run-loop wire-server)) + (else + ;; See note above http-run-loop: use (number? pid) not + ;; (eq? pid 0) to discriminate Python-tier's int-0 from #f. + (let ((http-pid (fork-self))) + (cond + ((not (number? http-pid)) + (display ";;; WARN -- fork-self failed; running wire-only") + (newline) + (tcp-close http-server) + (display "gpu-worker listening on port ") + (display port) (newline) + (run-loop wire-server)) + ((= http-pid 0) + ;; child: HTTP loop + (tcp-close wire-server) + (display "gpu-worker HTTP listening on port ") + (display http-port) (newline) + (http-run-loop http-server)) + (else + ;; parent: wire-TCP loop (existing behavior) + (tcp-close http-server) + (display "gpu-worker listening on port ") + (display port) (newline) + (run-loop wire-server))))))))) ;; (main) ; uncomment to run; needs the cuda-shake-fanout binary ;; on disk + spawn-process-stdio primitive per tier diff --git a/examples/cuda-fanout/http-listener.lsp b/examples/cuda-fanout/http-listener.lsp new file mode 100644 index 0000000..4b9c586 --- /dev/null +++ b/examples/cuda-fanout/http-listener.lsp @@ -0,0 +1,194 @@ +;;; http-listener.lsp -- minimal HTTP/1.1 request/response on top of +;;; tcp-recv / tcp-send. Used by gpu-worker.lsp to expose its +;;; handle-request dispatcher on a second port so browser callers +;;; (XHR POST from the playground) can hit bend without speaking +;;; the raw wire framing. +;;; +;;; Scope: just enough HTTP for our use case -- +;;; - POST
-> dispatch body as S-expression +;;; - OPTIONS -> 204 + CORS preflight +;;; - Connection: close after every response (no keep-alive) +;;; - Content-Length is the only body-framing we understand +;;; (no chunked encoding; the playground client doesn't use it) +;;; +;;; Portable across all three tiers: uses only tcp-recv / tcp-send / +;;; substring / string=? / string-length / string-append / +;;; string->number / number->string. Avoids string-contains and +;;; string-index, which the asm tier doesn't ship. +;;; +;;; License: AGPLv3 (matches lumbda). + +;;; -- portable substring search --------------------------------- + +;; find-substring haystack needle start -> index of first match at >= start, +;; or -1 if not found. O(n*m) — fine for HTTP header parsing. +(define (find-substring haystack needle start) + (let ((hlen (string-length haystack)) + (nlen (string-length needle))) + (cond + ((= nlen 0) start) + ((> (+ start nlen) hlen) -1) + (else + (let loop ((i start)) + (cond + ((> (+ i nlen) hlen) -1) + ((string=? (substring haystack i (+ i nlen)) needle) i) + (else (loop (+ i 1))))))))) + +;; skip-spaces s i end -> advance i past ASCII space and tab chars. +(define (skip-spaces s i end) + (cond + ((>= i end) end) + ((or (string=? (substring s i (+ i 1)) " ") + (string=? (substring s i (+ i 1)) "\t")) + (skip-spaces s (+ i 1) end)) + (else i))) + +;;; -- request-line parsers -------------------------------------- + +;; "POST /path HTTP/1.1" -> "POST" +(define (request-line-method line) + (let ((sp (find-substring line " " 0))) + (cond + ((< sp 0) line) + (else (substring line 0 sp))))) + +;; "POST /path HTTP/1.1" -> "/path" +(define (request-line-path line) + (let ((sp1 (find-substring line " " 0))) + (cond + ((< sp1 0) "/") + (else + (let ((sp2 (find-substring line " " (+ sp1 1)))) + (cond + ((< sp2 0) (substring line (+ sp1 1) (string-length line))) + (else (substring line (+ sp1 1) sp2)))))))) + +;;; -- Content-Length scan --------------------------------------- + +;; Accepts either capitalized "Content-Length:" or lowercase +;; "content-length:" — the only two casings browsers and curl send. +(define (find-content-length-idx headers-str) + (let ((a (find-substring headers-str "Content-Length:" 0)) + (b (find-substring headers-str "content-length:" 0))) + (cond + ((and (>= a 0) (>= b 0)) (cond ((< a b) a) (else b))) + ((>= a 0) a) + ((>= b 0) b) + (else -1)))) + +(define (find-content-length headers-str) + (let ((idx (find-content-length-idx headers-str))) + (cond + ((< idx 0) 0) + (else + (let* ((line-end (find-substring headers-str "\r\n" idx)) + (eol (cond ((< line-end 0) (string-length headers-str)) + (else line-end))) + (colon (find-substring headers-str ":" idx)) + (val-start (cond ((or (< colon 0) (>= colon eol)) eol) + (else (+ colon 1)))) + (trimmed (skip-spaces headers-str val-start eol)) + (val (substring headers-str trimmed eol)) + (n (string->number val))) + (cond ((eq? n #f) 0) + ((< n 0) 0) + (else n))))))) + +;;; -- request reader -------------------------------------------- + +;; read-http-request sock +;; -> (list method path body) on success +;; -> #f on malformed / closed +;; +;; Loops tcp-recv until we have the full header block (terminator +;; "\r\n\r\n"), parses request-line + Content-Length, then reads +;; the body (Content-Length bytes; may already be partly in the +;; recv buffer past the header terminator). +;; +;; Caps headers at 64 KiB to refuse oversized payloads — same cap +;; as nginx default. Body is capped only by Content-Length itself. +(define *http-max-header-bytes* 65536) + +(define (read-http-request sock) + (let loop ((acc "")) + (cond + ((>= (string-length acc) *http-max-header-bytes*) #f) + (else + (let ((sep (find-substring acc "\r\n\r\n" 0))) + (cond + ((>= sep 0) (parse-http-request-with-body acc sep sock)) + (else + (let ((chunk (tcp-recv sock 4096))) + (cond + ((eq? chunk #f) #f) + ((= (string-length chunk) 0) #f) + (else (loop (string-append acc chunk)))))))))))) + +(define (parse-http-request-with-body acc sep sock) + (let* ((headers-str (substring acc 0 sep)) + (body-prefix (substring acc (+ sep 4) (string-length acc))) + (req-line-end (find-substring headers-str "\r\n" 0)) + (req-line (cond ((>= req-line-end 0) (substring headers-str 0 req-line-end)) + (else headers-str))) + (method (request-line-method req-line)) + (path (request-line-path req-line)) + (clen (find-content-length headers-str))) + (cond + ((= clen 0) (list method path "")) + (else + (let ((have (string-length body-prefix))) + (cond + ((>= have clen) + (list method path (substring body-prefix 0 clen))) + (else + (let ((more (recv-exact sock (- clen have)))) + (cond + ((eq? more #f) #f) + (else (list method path (string-append body-prefix more)))))))))))) + +;;; -- response writer ------------------------------------------- + +(define *http-cors-headers* + (string-append + "Access-Control-Allow-Origin: *\r\n" + "Access-Control-Allow-Methods: POST, OPTIONS\r\n" + "Access-Control-Allow-Headers: Content-Type\r\n" + "Access-Control-Max-Age: 86400\r\n")) + +(define (http-status-text code) + (cond + ((= code 200) "200 OK") + ((= code 204) "204 No Content") + ((= code 400) "400 Bad Request") + ((= code 405) "405 Method Not Allowed") + ((= code 500) "500 Internal Server Error") + (else (string-append (number->string code) " Status")))) + +;; write-http-response sock status-code body content-type +;; Sends a fully-formed HTTP/1.1 response (status line + CORS + +;; Content-Length + Connection: close + body). Caller closes sock. +(define (write-http-response sock status body content-type) + (let* ((body-len (string-length body)) + (response + (string-append + "HTTP/1.1 " (http-status-text status) "\r\n" + *http-cors-headers* + "Content-Type: " content-type "\r\n" + "Content-Length: " (number->string body-len) "\r\n" + "Connection: close\r\n" + "\r\n" + body))) + (tcp-send sock response))) + +;; write-http-options-response sock +;; CORS preflight — 204 No Content + CORS headers, no body. +(define (write-http-options-response sock) + (let ((response + (string-append + "HTTP/1.1 " (http-status-text 204) "\r\n" + *http-cors-headers* + "Content-Length: 0\r\n" + "Connection: close\r\n" + "\r\n"))) + (tcp-send sock response))) diff --git a/examples/cuda-fanout/plans/bend-http-deploy.md b/examples/cuda-fanout/plans/bend-http-deploy.md new file mode 100644 index 0000000..19e58ff --- /dev/null +++ b/examples/cuda-fanout/plans/bend-http-deploy.md @@ -0,0 +1,175 @@ +# Bend HTTP deploy — Caddy + DNS proposals (NOT applied) + +Phase 1 of bend's HTTP path (port 8321) lands inside the lumbda repo +in this commit: `gpu-worker.lsp` now listens on both `:8320` wire and +`:8321` HTTP, sharing one `handle-request` dispatcher. This document +holds the *operational* deltas — to be applied by fox in +`~/git/foxhop-pillar`, `~/git/foxhop-states`, and +`~/git/proxy.unturf.com` after review. + +Two access tiers, both supported, both compatible: + +| Audience | URL | Gate | What changes here | +|---|---|---|---| +| Anyone (public adoption) | `http://localhost:8321/` (their own machine) | Browser localhost exception | Nothing in fox's infra — users run `make gpu-worker` on their own host. | +| Fox (personal remote access) | `https://bend.unturf.com/` (proposed) | proxy.unturf.com `@trusted remote_ip` allowlist | proxy.unturf.com vhost + ai.foxhop.net vhost + DNS A record | + +The playground placeholder stays `http://localhost:8321/` to teach +the right pattern to visitors. Fox personally pastes the remote URL +into the field when traveling. + +## 1. Public DNS — `bend.unturf.com` A record + +Whatever zone-authority manages `unturf.com` (per CLAUDE.md the +PowerDNS master lives on `proxy.uncloseai.com:22222`): + +``` +bend.unturf.com. 3600 IN A 142.93.73.64 +``` + +The IP is the existing `proxy.unturf.com` edge (the same address the +`@trusted` matcher in `ai-foxhop-net.sls` already accepts). + +Apply via: +```bash +ssh -i ~/.ssh/digitalocean -p 22222 root@proxy.uncloseai.com \ + "pdnsutil add-record unturf.com bend A 3600 142.93.73.64 && \ + pdns_control notify unturf.com" +``` + +## 2. proxy.unturf.com — new public vhost + +**File:** `~/git/proxy.unturf.com/ingress/Caddyfile` + +Insert a new vhost block (place near the other `*.unturf.com` ones; +exact location not load-bearing since Caddy matches by host): + +```caddy +# bend.unturf.com — personal remote access to fox's bend mesh. +# NOT public: gated by a trusted-IP allowlist of fox's known IPs. +# Routes Host-rewritten through ai.foxhop.net so the existing +# @trusted edge-IP pattern at the LAN Caddy continues to gate. +bend.unturf.com { + @trusted_fox { + # TODO(fox): replace with fox's known IPs. Examples: + # remote_ipBrowsers cannot open raw TCP sockets, but they can fetch / XHR to http://localhost:<port> even from an HTTPS page (a permissive "potentially trustworthy" exception every major browser ships). So the playground field accepts a URL that points at a bend worker running on your own machine. No fox-owned ingress, no certificate, no proxy hop.
# 1. Start a worker on your laptop / desktop / homelab
+git clone https://lumbda.com/lumbda.git && cd lumbda
+make gpu-worker LUMBDA=python # CPU works; CUDA path lights up if nvcc is present
+# → "gpu-worker listening on port 8320"
+# → "gpu-worker HTTP listening on port 8321"
+
+# 2. In a separate shell, smoke-test the HTTP path
+curl -sS -X POST -H 'Content-Type: text/plain' \
+ --data-binary '(ping)' http://localhost:8321/
+# → (ok pong)
+
+# 3. Open https://lumbda.com/playground/ and paste into the ⚡ bend field:
+# http://localhost:8321/
+# Save, then in the REPL:
+# λ> (bend!-call '(ping))
+
+Both ports share the same handle-request dispatcher: whatever an op-head (ping, echo, cuda-shake-fanout, cuda-sim-ops-bin, …) returns over wire is what HTTP returns. Adding a new op-head exposes it on both transports automatically.
HTTP scope today is the S-expression text path — for browser-friendly recipes the server understands today, with no binary blobs crossing the wire. Binary modes (BSHK / BCGB / BSCP / BSRT / BSB3) stay wire-only because they exist for native callers who already hold the binary locally.
Phase 2 (deferred until authentication lands): upload an .lsp recipe describing a champion circuit, dispatch into a server-side factory that compiles the .bin on the worker's filesystem and bends it. Same playground field, same single endpoint. Auth gates write access; today a worker on the public internet would let any caller occupy your GPU.