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:
parent
e34fb1f7dc
commit
88e16c0ce2
8 changed files with 771 additions and 30 deletions
71
CLAUDE.md
71
CLAUDE.md
|
|
@ -35,6 +35,48 @@ Then ask fox about our mission.
|
||||||
- Every implementation (Python, C, GNU asm) carries a dedicated architecture diagram
|
- Every implementation (Python, C, GNU asm) carries a dedicated architecture diagram
|
||||||
- When explaining architecture, draft or reference a dot diagram first
|
- 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
|
## Implementations
|
||||||
|
|
||||||
| Impl | Path | Build | Test | REPL |
|
| Impl | Path | Build | Test | REPL |
|
||||||
|
|
@ -48,12 +90,18 @@ Then ask fox about our mission.
|
||||||
|
|
||||||
`examples/cuda-fanout/` ships `(bend ...)` — runtime decides per call
|
`examples/cuda-fanout/` ships `(bend ...)` — runtime decides per call
|
||||||
whether to evaluate locally or ship to a CUDA worker over our wire
|
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
|
- **8320 — wire-TCP** (length-prefixed S-expressions + binary magic
|
||||||
inputs because parser cost dominates.
|
`BSHK`/`BCGB`/`BSCP`/`BSRT`/`BSB3` blobs). The native path: used
|
||||||
- **Binary mode** (magic `BSHK` + raw bytes) — for huge payloads. 150x
|
by `bend.lsp`, asm clients, and anything that can open a raw
|
||||||
faster than S-exp at 1M inputs; bends past host hashlib by 12x.
|
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}`).
|
Workers run on any tier (`make gpu-worker LUMBDA={c,python,asm}`).
|
||||||
C tier ~9x faster than Python on small calls; binary mode equalizes
|
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
|
1-8 secp256k1 arithmetic landed on the foxhop side this session, so
|
||||||
the substrate has every piece it needs.
|
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
|
## Test Suites
|
||||||
|
|
||||||
- Python unit/integration: `tests.py` (571 tests)
|
- Python unit/integration: `tests.py` (571 tests)
|
||||||
|
|
|
||||||
|
|
@ -94,5 +94,11 @@ test: shake256-fanout test_roundtrip.py
|
||||||
bench: shake256-fanout bench.py
|
bench: shake256-fanout bench.py
|
||||||
python3 bench.py ./shake256-fanout
|
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:
|
clean:
|
||||||
rm -f shake256-fanout /tmp/cf-in.portal /tmp/cf-out.portal
|
rm -f shake256-fanout /tmp/cf-in.portal /tmp/cf-out.portal
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ cd examples/cuda-fanout
|
||||||
python3 -u ../../lumbda.py /tmp/launch-worker.lsp
|
python3 -u ../../lumbda.py /tmp/launch-worker.lsp
|
||||||
# → gpu-worker: ready cuda-shake-fanout ← ./shake256-fanout
|
# → gpu-worker: ready cuda-shake-fanout ← ./shake256-fanout
|
||||||
# → gpu-worker listening on port 8320 (BEND mnemonic — see below)
|
# → 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!:
|
# In another shell, drive via bend!:
|
||||||
python3 ../../lumbda.py smoke-bend.lsp
|
python3 ../../lumbda.py smoke-bend.lsp
|
||||||
|
|
@ -176,9 +177,45 @@ rest of the repo.
|
||||||
make all # compile via nvcc
|
make all # compile via nvcc
|
||||||
make test # round-trip vs hashlib.shake_256
|
make test # round-trip vs hashlib.shake_256
|
||||||
make bench # device vs host timing grid
|
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
|
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)
|
## Measured performance — where the GPU actually wins (and loses)
|
||||||
|
|
||||||
**Setup:** RTX 3090 (10,496 CUDA cores, sm_86) + i9-12900K host,
|
**Setup:** RTX 3090 (10,496 CUDA cores, sm_86) + i9-12900K host,
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,15 @@
|
||||||
;;; Requires the cuda binaries on disk; paths below.
|
;;; Requires the cuda binaries on disk; paths below.
|
||||||
|
|
||||||
(load "wire.lsp")
|
(load "wire.lsp")
|
||||||
|
(load "http-listener.lsp")
|
||||||
|
|
||||||
(define *worker-port* 8320)
|
(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*
|
(define *binary-shake-fanout*
|
||||||
;; Override via env or per host.
|
;; Override via env or per host.
|
||||||
"./shake256-fanout")
|
"./shake256-fanout")
|
||||||
|
|
@ -82,6 +89,16 @@
|
||||||
(or (string->number (car (cdr rest))) *worker-port*))
|
(or (string->number (car (cdr rest))) *worker-port*))
|
||||||
(else (loop (cdr rest))))))
|
(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))
|
(define *daemons* '()) ; alist (op-name . (stdin-port . stdout-port))
|
||||||
|
|
||||||
;;; -- daemon pool (per-tier stubs marked) -----------------------
|
;;; -- daemon pool (per-tier stubs marked) -----------------------
|
||||||
|
|
@ -707,24 +724,31 @@
|
||||||
;; record-cell-vram! in child still updates running max if larger seen.
|
;; record-cell-vram! in child still updates running max if larger seen.
|
||||||
(define *vram-per-cell-max-mib* 2500)
|
(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)
|
(define (vram-used-mib)
|
||||||
;; nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits
|
;; nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits
|
||||||
;; returns one integer line per GPU; sum if multi-GPU host.
|
;; returns one integer line per GPU; sum if multi-GPU host.
|
||||||
(let* ((pair (spawn-process-stdio
|
(cond
|
||||||
"/usr/bin/nvidia-smi"
|
((not (file-exists? "/usr/bin/nvidia-smi")) 0)
|
||||||
'("--query-gpu=memory.used"
|
(else
|
||||||
"--format=csv,noheader,nounits"))))
|
(let* ((pair (spawn-process-stdio
|
||||||
(cond
|
"/usr/bin/nvidia-smi"
|
||||||
((eq? pair #f) 0) ;; fail-open: assume 0 if nvidia-smi missing
|
'("--query-gpu=memory.used"
|
||||||
(else
|
"--format=csv,noheader,nounits"))))
|
||||||
(close-port (car pair)) ;; close stdin to child
|
(cond
|
||||||
(let loop ((sum 0))
|
((eq? pair #f) 0) ;; fail-open: assume 0 if nvidia-smi missing
|
||||||
(let ((line (read-line (cdr pair))))
|
(else
|
||||||
(cond
|
(close-port (car pair)) ;; close stdin to child
|
||||||
((eof-object? line) (close-port (cdr pair)) sum)
|
(let loop ((sum 0))
|
||||||
(else
|
(let ((line (read-line (cdr pair))))
|
||||||
(let ((n (string->number (string-trim line))))
|
(cond
|
||||||
(loop (+ sum (cond (n n) (else 0)))))))))))))
|
((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)
|
(define (string-trim s)
|
||||||
;; trim leading + trailing whitespace.
|
;; trim leading + trailing whitespace.
|
||||||
|
|
@ -858,6 +882,78 @@
|
||||||
(wire-send client resp)
|
(wire-send client resp)
|
||||||
(tcp-close client))))))
|
(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
|
;; Optional registration — only spawn the daemon when its binary is
|
||||||
;; reachable. Lets a worker host serve a subset of forms without
|
;; reachable. Lets a worker host serve a subset of forms without
|
||||||
;; failing to start because some bend form's daemon isn't installed.
|
;; failing to start because some bend form's daemon isn't installed.
|
||||||
|
|
@ -878,9 +974,19 @@
|
||||||
;; lands (see plans/form-A-day4-progress.md).
|
;; lands (see plans/form-A-day4-progress.md).
|
||||||
(define *secp-daemon-extra-args* '("--window-w" "4"))
|
(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)
|
(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-port* port)
|
||||||
|
(set! *worker-http-port* http-port)
|
||||||
(maybe-register-daemon! 'cuda-shake-fanout *binary-shake-fanout*)
|
(maybe-register-daemon! 'cuda-shake-fanout *binary-shake-fanout*)
|
||||||
(maybe-register-daemon! 'cuda-bignum-cgbn *binary-cgbn-batch*)
|
(maybe-register-daemon! 'cuda-bignum-cgbn *binary-cgbn-batch*)
|
||||||
(maybe-register-daemon! 'cuda-secp256k1-batched-mul
|
(maybe-register-daemon! 'cuda-secp256k1-batched-mul
|
||||||
|
|
@ -888,15 +994,42 @@
|
||||||
*secp-daemon-extra-args*)
|
*secp-daemon-extra-args*)
|
||||||
(maybe-register-daemon! 'cuda-radix-sort *binary-radix-sort*)
|
(maybe-register-daemon! 'cuda-radix-sort *binary-radix-sort*)
|
||||||
(maybe-register-daemon! 'cuda-blake3-tree *binary-blake3-fanout*)
|
(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
|
(cond
|
||||||
((eq? server #f)
|
((eq? wire-server #f)
|
||||||
(display ";;; ERROR -- tcp-listen failed on port ")
|
(display ";;; ERROR -- tcp-listen failed on wire port ")
|
||||||
(display port) (newline))
|
(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 "gpu-worker listening on port ")
|
||||||
(display port) (newline)
|
(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
|
;; (main) ; uncomment to run; needs the cuda-shake-fanout binary
|
||||||
;; on disk + spawn-process-stdio primitive per tier
|
;; on disk + spawn-process-stdio primitive per tier
|
||||||
|
|
|
||||||
194
examples/cuda-fanout/http-listener.lsp
Normal file
194
examples/cuda-fanout/http-listener.lsp
Normal 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)))
|
||||||
175
examples/cuda-fanout/plans/bend-http-deploy.md
Normal file
175
examples/cuda-fanout/plans/bend-http-deploy.md
Normal 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.
|
||||||
105
examples/cuda-fanout/smoke-bend-http.sh
Executable file
105
examples/cuda-fanout/smoke-bend-http.sh
Executable 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 ==="
|
||||||
|
|
@ -30,19 +30,49 @@
|
||||||
make gpu-worker
|
make gpu-worker
|
||||||
# → builds examples/cuda-fanout/shake256-fanout
|
# → builds examples/cuda-fanout/shake256-fanout
|
||||||
# → builds the C tier (~10× faster wire orchestration than Python)
|
# → 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:
|
# Port 8320 = BEND mnemonic:
|
||||||
# 8 ~= B (implied infinity B flattened; bake a cake; baby & me)
|
# 8 ~= B (implied infinity B flattened; bake a cake; baby & me)
|
||||||
# 3 ~= E (backward)
|
# 3 ~= E (backward)
|
||||||
# 2 ~= N (pivoted 90 degrees)
|
# 2 ~= N (pivoted 90 degrees)
|
||||||
# 0 ~= D (flattened)
|
# 0 ~= D (flattened)
|
||||||
|
# Port 8321 = 8320 + 1 (HTTP is always wire-port + 1, override via --http-port).
|
||||||
|
|
||||||
# Override tier (default port stays 8320 / BEND):
|
# Override tier (default port stays 8320 / BEND):
|
||||||
make gpu-worker LUMBDA=python # easier debugging on port 8320
|
make gpu-worker LUMBDA=python # easier debugging
|
||||||
make gpu-worker LUMBDA=asm # smallest footprint on port 8320
|
make gpu-worker LUMBDA=asm # smallest footprint
|
||||||
# Override port too (only when running a second worker on the same host):
|
# 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:<port></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 && 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))</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>, …) 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>
|
||||||
|
|
||||||
<section id="call">
|
<section id="call">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue