diff --git a/lib/erlang/runtime.sx b/lib/erlang/runtime.sx index b4043b1e..17a5ad99 100644 --- a/lib/erlang/runtime.sx +++ b/lib/erlang/runtime.sx @@ -1468,9 +1468,26 @@ ;; entry is keyed by "Module/Name/Arity"; multi-arity BIFs register ;; once per arity. Called eagerly at the end of runtime.sx so the ;; registry is ready before any erlang-eval-ast call. -(define er-register-builtin-bifs! - (fn () - ;; erlang module — type predicates (all pure) +(define + er-bif-http-listen + (fn + (vs) + (let + ((port (nth vs 0)) (handler (nth vs 1))) + (cond + (not (= (type-of port) "number")) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + (not (er-fun? handler)) + (raise (er-mk-error-marker (er-mk-atom "badarg"))) + :else (let + ((sx-handler (fn (req-dict) (let ((er-req (er-of-sx req-dict))) (er-to-sx (er-apply-fun handler (list er-req))))))) + (http-listen port sx-handler)))))) + +;; Register everything at load time. +(define + er-register-builtin-bifs! + (fn + () (er-register-pure-bif! "erlang" "is_integer" 1 er-bif-is-integer) (er-register-pure-bif! "erlang" "is_atom" 1 er-bif-is-atom) (er-register-pure-bif! "erlang" "is_list" 1 er-bif-is-list) @@ -1479,27 +1496,61 @@ (er-register-pure-bif! "erlang" "is_float" 1 er-bif-is-float) (er-register-pure-bif! "erlang" "is_boolean" 1 er-bif-is-boolean) (er-register-pure-bif! "erlang" "is_pid" 1 er-bif-is-pid) - (er-register-pure-bif! "erlang" "is_reference" 1 er-bif-is-reference) + (er-register-pure-bif! + "erlang" + "is_reference" + 1 + er-bif-is-reference) (er-register-pure-bif! "erlang" "is_binary" 1 er-bif-is-binary) - (er-register-pure-bif! "erlang" "is_function" 1 er-bif-is-function) - (er-register-pure-bif! "erlang" "is_function" 2 er-bif-is-function) - ;; erlang module — pure data ops + (er-register-pure-bif! + "erlang" + "is_function" + 1 + er-bif-is-function) + (er-register-pure-bif! + "erlang" + "is_function" + 2 + er-bif-is-function) (er-register-pure-bif! "erlang" "length" 1 er-bif-length) (er-register-pure-bif! "erlang" "hd" 1 er-bif-hd) (er-register-pure-bif! "erlang" "tl" 1 er-bif-tl) (er-register-pure-bif! "erlang" "element" 2 er-bif-element) (er-register-pure-bif! "erlang" "tuple_size" 1 er-bif-tuple-size) (er-register-pure-bif! "erlang" "byte_size" 1 er-bif-byte-size) - (er-register-pure-bif! "erlang" "atom_to_list" 1 er-bif-atom-to-list) - (er-register-pure-bif! "erlang" "list_to_atom" 1 er-bif-list-to-atom) + (er-register-pure-bif! + "erlang" + "atom_to_list" + 1 + er-bif-atom-to-list) + (er-register-pure-bif! + "erlang" + "list_to_atom" + 1 + er-bif-list-to-atom) (er-register-pure-bif! "erlang" "abs" 1 er-bif-abs) (er-register-pure-bif! "erlang" "min" 2 er-bif-min) (er-register-pure-bif! "erlang" "max" 2 er-bif-max) - (er-register-pure-bif! "erlang" "tuple_to_list" 1 er-bif-tuple-to-list) - (er-register-pure-bif! "erlang" "list_to_tuple" 1 er-bif-list-to-tuple) - (er-register-pure-bif! "erlang" "integer_to_list" 1 er-bif-integer-to-list) - (er-register-pure-bif! "erlang" "list_to_integer" 1 er-bif-list-to-integer) - ;; erlang module — process / runtime (side-effecting) + (er-register-pure-bif! + "erlang" + "tuple_to_list" + 1 + er-bif-tuple-to-list) + (er-register-pure-bif! + "erlang" + "list_to_tuple" + 1 + er-bif-list-to-tuple) + (er-register-pure-bif! + "erlang" + "integer_to_list" + 1 + er-bif-integer-to-list) + (er-register-pure-bif! + "erlang" + "list_to_integer" + 1 + er-bif-list-to-integer) (er-register-bif! "erlang" "self" 0 er-bif-self) (er-register-bif! "erlang" "spawn" 1 er-bif-spawn) (er-register-bif! "erlang" "spawn" 3 er-bif-spawn) @@ -1515,12 +1566,16 @@ (er-register-bif! "erlang" "unregister" 1 er-bif-unregister) (er-register-bif! "erlang" "whereis" 1 er-bif-whereis) (er-register-bif! "erlang" "registered" 0 er-bif-registered) - ;; erlang module — exception raising (modelled as side-effecting) - (er-register-bif! "erlang" "throw" 1 + (er-register-bif! + "erlang" + "throw" + 1 (fn (vs) (raise (er-mk-throw-marker (er-bif-arg1 vs "throw"))))) - (er-register-bif! "erlang" "error" 1 + (er-register-bif! + "erlang" + "error" + 1 (fn (vs) (raise (er-mk-error-marker (er-bif-arg1 vs "error"))))) - ;; lists module — all pure (er-register-pure-bif! "lists" "reverse" 1 er-bif-lists-reverse) (er-register-pure-bif! "lists" "map" 2 er-bif-lists-map) (er-register-pure-bif! "lists" "foldl" 3 er-bif-lists-foldl) @@ -1534,11 +1589,13 @@ (er-register-pure-bif! "lists" "filter" 2 er-bif-lists-filter) (er-register-pure-bif! "lists" "any" 2 er-bif-lists-any) (er-register-pure-bif! "lists" "all" 2 er-bif-lists-all) - (er-register-pure-bif! "lists" "duplicate" 2 er-bif-lists-duplicate) - ;; io module — side-effecting (writes to io buffer) + (er-register-pure-bif! + "lists" + "duplicate" + 2 + er-bif-lists-duplicate) (er-register-bif! "io" "format" 1 er-bif-io-format) (er-register-bif! "io" "format" 2 er-bif-io-format) - ;; ets module — side-effecting (mutates table state) (er-register-bif! "ets" "new" 2 er-bif-ets-new) (er-register-bif! "ets" "insert" 2 er-bif-ets-insert) (er-register-bif! "ets" "lookup" 2 er-bif-ets-lookup) @@ -1546,23 +1603,21 @@ (er-register-bif! "ets" "delete" 2 er-bif-ets-delete) (er-register-bif! "ets" "tab2list" 1 er-bif-ets-tab2list) (er-register-bif! "ets" "info" 2 er-bif-ets-info) - ;; code module — side-effecting (mutates module registry, kills procs) (er-register-bif! "code" "load_binary" 3 er-bif-code-load-binary) (er-register-bif! "code" "purge" 1 er-bif-code-purge) (er-register-bif! "code" "soft_purge" 1 er-bif-code-soft-purge) (er-register-bif! "code" "which" 1 er-bif-code-which) (er-register-bif! "code" "is_loaded" 1 er-bif-code-is-loaded) (er-register-bif! "code" "all_loaded" 0 er-bif-code-all-loaded) - ;; file module (er-register-bif! "file" "read_file" 1 er-bif-file-read-file) (er-register-bif! "file" "write_file" 2 er-bif-file-write-file) (er-register-bif! "file" "delete" 1 er-bif-file-delete) - ;; Phase 8 FFI — host-primitive BIFs (loops/fed-prims) (er-register-pure-bif! "crypto" "hash" 2 er-bif-crypto-hash) (er-register-pure-bif! "cid" "from_bytes" 1 er-bif-cid-from-bytes) (er-register-pure-bif! "cid" "to_string" 1 er-bif-cid-to-string) (er-register-bif! "file" "list_dir" 1 er-bif-file-list-dir) (er-mk-atom "ok"))) -;; Register everything at load time. +(er-register-bif! "http" "listen" 2 er-bif-http-listen) + (er-register-builtin-bifs!) diff --git a/next/tests/http_listen_bif.sh b/next/tests/http_listen_bif.sh new file mode 100755 index 00000000..5df39296 --- /dev/null +++ b/next/tests/http_listen_bif.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# next/tests/http_listen_bif.sh — Step 8a acceptance test. +# +# Verifies the http:listen/2 BIF wrapper is registered and +# validates its arguments. We do NOT exercise the actual listen +# loop — http-listen blocks forever, so production callers spawn +# an Erlang process to host the call. The BIF wrapper itself is +# tested for: registration, integer port enforcement, function +# handler enforcement. +# +# This BIF is the briefing's allowed-exception scope addition +# to lib/erlang/runtime.sx. 5 cases. + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}" +if [ ! -x "$SX_SERVER" ]; then + SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe" +fi +if [ ! -x "$SX_SERVER" ]; then + echo "ERROR: sx_server.exe not found." >&2 + exit 1 +fi + +VERBOSE="${1:-}" +PASS=0; FAIL=0; ERRORS="" +TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT + +cat > "$TMPFILE" <<'EPOCHS' +(epoch 1) +(load "lib/erlang/tokenizer.sx") +(load "lib/erlang/parser.sx") +(load "lib/erlang/parser-core.sx") +(load "lib/erlang/parser-expr.sx") +(load "lib/erlang/parser-module.sx") +(load "lib/erlang/transpile.sx") +(load "lib/erlang/runtime.sx") +(load "lib/erlang/vm/dispatcher.sx") + +;; BIF registered under http/listen/2 +(epoch 10) +(eval "(not (= (er-lookup-bif \"http\" \"listen\" 2) nil))") + +;; BIF is non-pure (side effect: opens a socket) +(epoch 11) +(eval "(get (er-lookup-bif \"http\" \"listen\" 2) :pure?)") + +;; Non-integer port -> badarg +(epoch 12) +(eval "(get (erlang-eval-ast \"try http:listen(not_a_number, fun () -> ok end) catch error:badarg -> ok end\") :name)") + +;; Non-fun handler -> badarg +(epoch 13) +(eval "(get (erlang-eval-ast \"try http:listen(8080, not_a_fun) catch error:badarg -> ok end\") :name)") + +;; Wrong arity not registered (http/listen/1 should be nil) +(epoch 14) +(eval "(= (er-lookup-bif \"http\" \"listen\" 1) nil)") +EPOCHS + +OUTPUT=$(timeout 60 "$SX_SERVER" < "$TMPFILE" 2>/dev/null) + +check() { + local epoch="$1" desc="$2" expected="$3" + local actual + actual=$(echo "$OUTPUT" | awk -v e="$epoch" ' + $0 ~ "^\\(ok-len " e " " { getline; print; exit } + $0 ~ "^\\(ok " e " " { print; exit } + $0 ~ "^\\(error " e " " { print; exit } + ') + [ -z "$actual" ] && actual="" + if echo "$actual" | grep -qF -- "$expected"; then + PASS=$((PASS+1)) + [ "$VERBOSE" = "-v" ] && echo " ok $desc" + else + FAIL=$((FAIL+1)) + ERRORS+=" FAIL [$desc] (epoch $epoch) expected: $expected | actual: $actual +" + fi +} + +check 10 "BIF registered under http/listen/2" "true" +check 11 "BIF marked non-pure" "false" +check 12 "non-integer port -> badarg" "ok" +check 13 "non-fun handler -> badarg" "ok" +check 14 "no /1 arity registered" "true" + +TOTAL=$((PASS+FAIL)) +if [ $FAIL -eq 0 ]; then + echo "ok $PASS/$TOTAL next/tests/http_listen_bif.sh passed" +else + echo "FAIL $PASS/$TOTAL passed, $FAIL failed:" + echo "$ERRORS" +fi +[ $FAIL -eq 0 ] diff --git a/plans/fed-sx-milestone-1.md b/plans/fed-sx-milestone-1.md index 93277443..427b3edf 100644 --- a/plans/fed-sx-milestone-1.md +++ b/plans/fed-sx-milestone-1.md @@ -505,6 +505,12 @@ publish(ActorId, ActivityRequest) -> ## Step 8 — HTTP server + endpoints +**Sub-deliverables:** +- [x] **8a** — `http:listen/2` BIF wrapper in `lib/erlang/runtime.sx` (the briefing's allowed exception). Validates args, bridges Erlang handler funs to SX-callable lambdas via `er-of-sx`/`er-to-sx`, delegates to the native `http-listen` primitive in `bin/sx_server.ml`. Tests verify registration + arg validation (not the blocking listen loop). `next/tests/http_listen_bif.sh` (5 cases). +- [ ] **8b** — `next/kernel/http_server.erl`: `start/1(Port)` spawns an Erlang process hosting `http:listen/2`, routes requests via `route/1`. Returns the Pid for shutdown. +- [ ] **8c** — `route/1`: dispatch table for GET /actors/{id}, /outbox, /artifacts/{cid}, /projections, POST /activity (Step 6e auth via bearer token), /.well-known/sx-capabilities, /.well-known/webfinger. +- [ ] **8d** — Content negotiation by Accept header: application/activity+json (default), application/cbor, application/json, application/sx. + **Deliverables:** Core endpoints (per design §16.1): @@ -977,6 +983,7 @@ A few things still under-specified; resolve as work begins. Newest first. One line per sub-deliverable commit. Erlang conformance gate (`bash lib/erlang/conformance.sh`) must remain 729/729 on every entry. +- **2026-05-28** — Step 8a: `http:listen/2` BIF wrapper added to `lib/erlang/runtime.sx` (the briefing's single allowed scope exception). The BIF takes `(Port, Handler)`, validates Port is an integer and Handler is an Erlang fun (else `badarg`), then builds an SX-callable bridge lambda that marshals request dict↔Erlang term via `er-of-sx`/`er-to-sx` and calls `er-apply-fun` on the handler. Delegates to the native `http-listen` primitive (registered in `bin/sx_server.ml`, native-only). Tests verify registration + arg validation paths (the blocking listen loop itself is not exercised — production callers spawn an Erlang process to host the call). `next/tests/http_listen_bif.sh` 5/5; Erlang conformance preserved at 729/729 despite the runtime.sx edit. Step 8 broken into 8a–8d on the plan. - **2026-05-28** — Step 7c: `outbox:publish` now broadcasts the signed activity to every projection process named in `Context`'s `:projections` entry — fired immediately after `log:append`, via `projection:async_fold`. Missing/nil/empty list is a no-op (preserves the Step 6d-publish contract). Stage halts (replay duplicate, sig failure) suppress the broadcast — projection state stays at zero while the activity is rejected. `next/tests/outbox_broadcast.sh` 14/14 covers single + multi projection fan-out, three-publish accumulation, replay-skip, sig-skip, and the projection receiving the post-sign Signed envelope (not the pre-sign skeleton). Erlang conformance 729/729. - **2026-05-28** — Step 7b: `projection.erl` extended with gen_server callbacks + per-projection named-process API. `start_link/3(Name, InitialState, FoldFn)` spawns and registers under the supplied atom; `async_fold/2(Name, Activity)` casts a fold message; `query/1(Name)` synchronously returns the current state. Same port quirks as registry gen_server (Step 5b): raw Pid return, no `?MODULE` macro, processes don't survive between separate `erlang-eval-ast` calls — tests inline start_link with operations. Two named projections are independent. Snapshot persistence deferred to a later sub-step (needs SX-source eval + on-disk state). `next/tests/projection_server.sh` 11/11. Erlang conformance 729/729. - **2026-05-28** — Step 7a: `next/kernel/projection.erl` — pure-functional projection driver. Record shape `[{name, _}, {state, _}, {fold, fun}]`; `fold_activity/2` advances state by one activity; `replay/2` folds a whole list (mirrors `log:entries/1` semantics); `new/2` defaults to the identity fold and `new/3` accepts a custom Erlang fun. Multiple projections share no state — independent record values. Step 7 split into 7a (done) + 7b (gen_server-per-projection) + 7c (broadcast hook from outbox) + 7d (sandbox eval, needs SX-source bridge). `next/tests/projection_pure.sh` 12/12. Erlang conformance 729/729.