bend: dual-port worker (8320 wire + 8321 http) — playground onramp

Each gpu-worker.lsp now listens on both wire-TCP (existing :8320) and
HTTP/1.1+CORS (new :8321), sharing one handle-request dispatcher. Lets
a tab on https://lumbda.com/playground/ POST to its own machine via
http://localhost:8321/ — browsers permit localhost from HTTPS origins
without TLS, so no proxy, no cert, no fox-owned infra required for the
decentralized run-your-own-bend story.

main() forks at startup: child runs http-run-loop on :8321, parent
keeps existing run-loop on :8320. Adding a new op-head to handle-request
exposes it over both transports automatically. Binary modes
(BSHK/BCGB/BSCP/BSRT/BSB3) stay wire-only — they exist for native
callers who already cache the binary locally; browser callers send
S-expression recipes the worker dispatches the same way.

Two latent defects fixed to make CPU-only and Python-tier hosts work:
- vram-used-mib now file-exists? guards /usr/bin/nvidia-smi. Python
  tier's spawn-process-stdio raises FileNotFoundError on missing
  binary, not returning #f as the prior code expected, which crashed
  every worker on a CPU-only laptop.
- fork-self return discriminated via (number? pid) not (eq? pid 0).
  Python tier's (eq? 0 #f) returns #t because == conflates int 0
  with bool False; pre-existing run-loop has the same risk but
  C/asm tier (identity eq?) masks it for the production case.

Phase 2 (server-side factory ops: compile uploaded .lsp recipes into
.bin before bending — the foxhop champion-circuit workflow) deferred
until authentication lands; today a worker on the public internet
would let any caller occupy our GPU.

Operational Caddy + DNS proposals in plans/bend-http-deploy.md cover
the personal-remote-access endpoint chain (proxy.unturf.com edge →
ai.foxhop.net Caddy → 3090-ai:8321) gated by trusted-IP allowlist —
applied separately.

Also codifies the playground "CSS Grid only, never flexbox" rule in
CLAUDE.md: all www/ and wasm/ stylesheets are already grid-only;
documenting the invariant so future edits don't drift.

Tests: smoke-bend-http.sh — (ping)→(ok pong), unknown-op fallback,
OPTIONS CORS preflight — all PASS. Wire path unchanged, verified
round-trip via 8-digit-prefix framing.
This commit is contained in:
russell@unturf.com 2026-06-14 17:32:46 -04:00
parent e34fb1f7dc
commit 88e16c0ce2
No known key found for this signature in database
8 changed files with 771 additions and 30 deletions

View file

@ -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)

View file

@ -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

View file

@ -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,

View file

@ -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,9 +724,16 @@
;; 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.
(cond
((not (file-exists? "/usr/bin/nvidia-smi")) 0)
(else
(let* ((pair (spawn-process-stdio
"/usr/bin/nvidia-smi"
'("--query-gpu=memory.used"
@ -724,7 +748,7 @@
((eof-object? line) (close-port (cdr pair)) sum)
(else
(let ((n (string->number (string-trim line))))
(loop (+ sum (cond (n n) (else 0)))))))))))))
(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

View file

@ -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 <body> -> 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)))

View file

@ -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_ip <home-public-ip>
# remote_ip 100.64.0.0/10 # Tailscale CGNAT range
# remote_ip <work-public-ip>
remote_ip <FOX-IP-PLACEHOLDER>
}
handle @trusted_fox {
reverse_proxy https://ai.foxhop.net {
header_up Host bend.foxhop.net
header_up X-Real-IP {http.request.remote.host}
header_up X-Forwarded-For {http.request.remote.host}
header_up X-Forwarded-Proto {http.request.scheme}
transport http {
tls
tls_server_name ai.foxhop.net
}
}
}
handle {
respond "Access denied — personal endpoint" 403
}
}
```
## 3. ai.foxhop.net Caddy — new LAN vhost
**File:** `~/git/foxhop-pillar/caddy/ai-foxhop-net.sls`
Insert between the existing `home.foxhop.net` block (lines 74-88)
and the next vhost. Mirrors the `home.foxhop.net` pattern exactly —
edge-IP gated, reverse_proxies to a specific worker on the LAN.
```yaml
bend.foxhop.net {
# === Trusted sources - ONLY edge proxy ===
@trusted {
remote_ip 142.93.73.64 # edge / proxy.unturf.com
}
handle @trusted {
# Forward HTTP/8321 of the bend worker mesh.
# Single backend today (3090-ai); fan out across the mesh
# once the single-host path is verified.
reverse_proxy 3090-ai.foxhop.net:8321
}
handle {
respond "Access denied - bend backend (edge proxy required)" 403
}
}
```
Apply via the usual pillar workflow:
```bash
cd ~/git/foxhop-pillar
git add caddy/ai-foxhop-net.sls
git commit -m "caddy: bend.foxhop.net vhost — proxy to 3090-ai:8321"
git push
# then trigger highstate on ai.foxhop.net via salt-master (home.foxhop.net)
```
## 4. Worker hosts — start bend on every GPU server
Per the discussion: each GPU server runs `gpu-worker.lsp` on both
8320 + 8321 so the Caddy vhost above can reverse_proxy to whichever
host is healthy.
Today the proposal pins backend to `3090-ai.foxhop.net:8321` (single
host MVP). To verify the dual-port mode is live on 3090-ai:
```bash
ssh 3090-ai.foxhop.net "ss -ltn | grep -E ':832[01]'"
# expect both 8320 and 8321 in LISTEN
```
A systemd unit (or whatever process manager 3090-ai uses for the
current wire-only worker) needs the unit's ExecStart to keep the
same args — no flag changes required since both ports come up by
default. Confirm `gpu-worker.lsp` was redeployed after this commit
lands in lumbda.
## 5. Future — fan-out across the 3-node mesh
Once 3090-ai HTTP path is verified, the ai.foxhop.net vhost can
upgrade to round-robin / failover across the existing mesh:
```yaml
bend.foxhop.net {
@trusted { remote_ip 142.93.73.64 }
handle @trusted {
reverse_proxy 3090-ai.foxhop.net:8321 \
ai.foxhop.net:8321 \
cammy.foxhop.net:8321 {
lb_policy least_conn
health_uri / # or a dedicated /healthz once added
health_interval 10s
}
}
handle { respond "Access denied" 403 }
}
```
The fleet logic in `examples/cuda-fanout/bend.lsp` (round-robin +
failover for native callers) and Caddy's `lb_policy` are
independent — both can fan across the same mesh; neither needs to
know about the other.
## What we explicitly chose NOT to do
- **No public-internet bend endpoint without auth.** `bend.unturf.com`
is fox-only via trusted-IP. Public-internet bend would need
authentication first (Phase 2 dependency).
- **No bend.foxhop.net split-horizon DNS for public access.** Keeping
`bend.foxhop.net` LAN-only and `bend.unturf.com` as the personal-
remote name avoids the split-horizon confusion that bites every
six months.
- **No factory recipe handling in this commit.** Phase 2 lands
server-side compilation of `.lsp` recipes after auth ships.

View file

@ -0,0 +1,105 @@
#!/bin/bash
# smoke-bend-http.sh — exercise gpu-worker.lsp's HTTP/8321 path.
#
# Boots gpu-worker on a test port pair (avoids prod 8320/8321), then:
# 1. curl -X POST '(ping)' → expect "(ok pong)"
# 2. curl -X POST '(echo "hello")' → expect "(echo \"hello\")"
# (current handle-request has no 'echo head, returns unknown-op —
# so we expect (error (unknown-op echo)) instead, which still
# round-trips the wire correctly)
# 3. curl -X OPTIONS preflight → expect 204 + CORS headers
#
# Memory-discipline pattern lifted from lumbda CLAUDE.md (asm heap
# never shrinks): kernel cap, trap cleanup, wall-clock timeout,
# explicit PID kill, straggler verify.
set -e
ulimit -v 524288 # 512 MB kernel cap — process gets SIGKILL at cap
# Test ports — high so they never collide with prod 8320/8321
WIRE_PORT=18320
HTTP_PORT=18321
# Pick tier: env override > default Python (always available, has GC)
LUMBDA="${LUMBDA:-$(cd "$(dirname "$0")/../.." && pwd)/lumbda.py}"
case "$LUMBDA" in
*.py) LUMBDA_RUN="python3 $LUMBDA" ;;
*) LUMBDA_RUN="$LUMBDA" ;;
esac
trap 'kill -9 $WORKER_PID 2>/dev/null || true;
pkill -9 -u "$USER" -f "gpu-worker.lsp --port $WIRE_PORT" 2>/dev/null || true' \
EXIT INT TERM
cd "$(dirname "$0")"
echo "=== smoke-bend-http (tier: $LUMBDA) ==="
# Boot worker — script-mode .lsp that loads gpu-worker.lsp + calls (main)
cat > /tmp/smoke-bend-http-boot.lsp <<EOF
(load "gpu-worker.lsp")
(main)
EOF
# Background the worker (foreground would block the test).
timeout 30 $LUMBDA_RUN /tmp/smoke-bend-http-boot.lsp \
--port $WIRE_PORT --http-port $HTTP_PORT > /tmp/smoke-bend-http-worker.log 2>&1 &
WORKER_PID=$!
# Wait up to 5s for HTTP port to come up
for i in 1 2 3 4 5 6 7 8 9 10; do
if exec 3<>/dev/tcp/127.0.0.1/$HTTP_PORT 2>/dev/null; then
exec 3<&-; exec 3>&-
break
fi
sleep 0.5
done
# ---- test 1: ping -----------------------------------------------
echo "[1/3] POST (ping) → expect (ok pong) ..."
PING_RESP=$(curl -sS -X POST -H 'Content-Type: text/plain' \
--data-binary '(ping)' "http://127.0.0.1:$HTTP_PORT/" --max-time 5)
echo " got: $PING_RESP"
if [ "$PING_RESP" != "(ok pong)" ]; then
echo " FAIL: expected (ok pong), got [$PING_RESP]"
echo " worker log:"
cat /tmp/smoke-bend-http-worker.log
exit 1
fi
echo " PASS"
# ---- test 2: unknown-op gracefully ------------------------------
echo "[2/3] POST (asdf 1 2) → expect (error (unknown-op asdf)) ..."
UNK_RESP=$(curl -sS -X POST -H 'Content-Type: text/plain' \
--data-binary '(asdf 1 2)' "http://127.0.0.1:$HTTP_PORT/" --max-time 5)
echo " got: $UNK_RESP"
case "$UNK_RESP" in
*unknown-op*) echo " PASS" ;;
*) echo " FAIL: expected unknown-op error, got [$UNK_RESP]"; exit 1 ;;
esac
# ---- test 3: OPTIONS preflight + CORS headers -------------------
echo "[3/3] OPTIONS / → expect 204 + CORS headers ..."
PREFLIGHT=$(curl -sSI -X OPTIONS "http://127.0.0.1:$HTTP_PORT/" --max-time 5)
echo "$PREFLIGHT" | head -5 | sed 's/^/ /'
case "$PREFLIGHT" in
*"204 No Content"*) ;;
*) echo " FAIL: expected 204, got [$PREFLIGHT]"; exit 1 ;;
esac
case "$PREFLIGHT" in
*"Access-Control-Allow-Origin"*) echo " PASS (CORS present)" ;;
*) echo " FAIL: missing CORS headers"; exit 1 ;;
esac
# Clean teardown
kill -9 $WORKER_PID 2>/dev/null || true
wait $WORKER_PID 2>/dev/null || true
# Straggler check — verify nothing left behind before reporting success
if pgrep -u "$USER" -f "gpu-worker.lsp --port $WIRE_PORT" >/dev/null; then
echo "STRAGGLER: gpu-worker on port $WIRE_PORT still running"
exit 1
fi
rm -f /tmp/smoke-bend-http-boot.lsp /tmp/smoke-bend-http-worker.log
echo "=== smoke-bend-http: PASS ==="

View file

@ -30,19 +30,49 @@
make gpu-worker
# → builds examples/cuda-fanout/shake256-fanout
# → builds the C tier (~10× faster wire orchestration than Python)
# → launches gpu-worker.lsp on port 8320 (BEND)
# → launches gpu-worker.lsp on TWO ports:
# 8320 — wire-TCP protocol (native callers: bend.lsp, asm clients)
# 8321 — HTTP/1.1 + CORS (browser callers: the playground/REPL tabs)
# Port 8320 = BEND mnemonic:
# 8 ~= B (implied infinity B flattened; bake a cake; baby &amp; me)
# 3 ~= E (backward)
# 2 ~= N (pivoted 90 degrees)
# 0 ~= D (flattened)
# Port 8321 = 8320 + 1 (HTTP is always wire-port + 1, override via --http-port).
# Override tier (default port stays 8320 / BEND):
make gpu-worker LUMBDA=python # easier debugging on port 8320
make gpu-worker LUMBDA=asm # smallest footprint on port 8320
make gpu-worker LUMBDA=python # easier debugging
make gpu-worker LUMBDA=asm # smallest footprint
# Override port too (only when running a second worker on the same host):
make gpu-worker LUMBDA=python PORT=8321 # second worker, off-BEND port</code></pre>
make gpu-worker LUMBDA=python PORT=9320 # second worker (wire 9320, http 9321)</code></pre>
</section>
<section id="run-your-own">
<h2>Run your own bend → use it from a browser tab</h2>
<p>Browsers cannot open raw TCP sockets, but they can <code>fetch</code> / XHR to <code>http://localhost:&lt;port&gt;</code> even from an HTTPS page (a permissive "potentially trustworthy" exception every major browser ships). So <strong>the playground field accepts a URL that points at a bend worker running on your own machine.</strong> No fox-owned ingress, no certificate, no proxy hop.</p>
<pre><code># 1. Start a worker on your laptop / desktop / homelab
git clone https://lumbda.com/lumbda.git &amp;&amp; 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:
# λ&gt; (bend!-call '(ping))</code></pre>
<p>Both ports share the same <code>handle-request</code> dispatcher: whatever an op-head (<code>ping</code>, <code>echo</code>, <code>cuda-shake-fanout</code>, <code>cuda-sim-ops-bin</code>, &hellip;) returns over wire is what HTTP returns. Adding a new op-head exposes it on both transports automatically.</p>
<p>HTTP scope today is the <strong>S-expression text path</strong> — for browser-friendly recipes the server understands today, with no binary blobs crossing the wire. Binary modes (<code>BSHK</code> / <code>BCGB</code> / <code>BSCP</code> / <code>BSRT</code> / <code>BSB3</code>) stay wire-only because they exist for native callers who already hold the binary locally.</p>
<p><em>Phase 2 (deferred until authentication lands):</em> upload an <code>.lsp</code> recipe describing a champion circuit, dispatch into a server-side factory that compiles the <code>.bin</code> 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.</p>
</section>
<section id="call">