Files
rose-ash/next/tests/http_server_tcp.sh
giles 0f85bd963a
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 25s
fed-sx-m1: Step 8b-start — http_server:start/1 + dict↔proplist marshaling; live TCP smoke 5/5
`next/kernel/http_server.erl` gains `start/1(Port)` + `start/2(Port, Cfg)`. Both spawn an Erlang process that hosts
the native `http:listen/2` accept loop with the Cfg-aware `route/2` as the handler.

The blocker — the BIF wrapper in `lib/erlang/runtime.sx` had no dict↔proplist marshaling, so Erlang handler funs
couldn't pattern-match on an opaque SX request dict — is resolved by a new family of helpers added next to `er-of-sx`
(which is left untouched so non-HTTP callers see no behavioural drift):

  er-request-dict-to-proplist   request dict -> [{method,<<>>},{path,<<>>},...] (atom keys)
  er-of-sx-deep                 recursive marshal: dicts -> binary-keyed proplist
  er-dict-to-header-proplist    headers: [{<<"content-type">>,<<"text/plain">>},...]
                                 (binary keys keep arbitrary user input out of the atom table)
  er-proplist-to-dict           response proplist -> SX dict for native serialiser
  er-proplist-fill!             dict-set! walker over a cons-of-2-tuples
  er-to-sx-deep                 recursive marshal: cons-of-2-tuples -> nested dict
  er-proplist-2tuple?           predicate distinguishing a header proplist from a binary body

`er-bif-http-listen`'s body is updated to route through the new pair instead of `er-of-sx` / `er-to-sx`. Existing
`http_listen_bif.sh` (Step 8a) still passes — the BIF's external contract (port + handler validation, registration)
hasn't changed, only the request/response shape the handler sees.

This commit also lands a small pre-existing unstaged refactor that was sitting in the same file (er-binary->string
helper above er-bif-http-listen, a "Register everything at load time." comment move, and the binary_to_list /
list_to_binary / er-iolist-walk! defines reshuffled into the er-register-builtin-bifs! body). The refactor was
agreed-out-of-scope earlier in the loop but was unblocked this iteration when the user OK'd progress on 8b-start.
Bundling it here keeps the lib/erlang/runtime.sx diff coherent.

Tests:
- `next/tests/http_marshal.sh` (10 cases) — marshaling unit tests: request dict → cons proplist; method as
  <<"GET">> via SX-side proplist walker; path-as-string roundtrip; nested headers reach through binary keys;
  response status/body field marshaling; nested headers reconstruct dict; full round-trip preserves status.
- `next/tests/http_server_start.sh` (6 cases) — structural verification: http_server module loaded, start bound
  in module env, marshalers defined as lambdas, http:listen BIF registered. Can't invoke spawn in an Erlang test
  because the cooperative scheduler (`er-sched-run-all!`) drains every runnable process before returning to the
  caller, and the listener's accept loop never exits.
- `next/tests/http_server_tcp.sh` (5 cases) — **first live end-to-end transport test in the milestone**: boots
  sx_server in background with FIFO-held stdin (~10s boot for all lib/erlang/*.sx loads + module compile +
  Unix.bind), then drives the listener via shell-side curl over real TCP. Verifies GET / → 200, GET
  /.well-known/sx-capabilities → 200, GET unknown → 404, POST /activity → 401 with no/bad bearer. Doubles as the
  smoke surface for 9a-tcp / 9b-tcp.

Erlang conformance **761/761** unchanged. All standing suites stay green (http_listen_bif 5/5, log_disk 12/12,
log_rotate 10/10, term_codec 18/18).

Step 8b-start ticked in plans/fed-sx-milestone-1.md. Remaining in the milestone: 9a-tcp / 9b-tcp — partly covered
by http_server_tcp.sh's smoke probes; the full curl-driven publish flows are the next iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 20:30:15 +00:00

144 lines
4.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# next/tests/http_server_tcp.sh — Step 9a-tcp live TCP smoke test.
#
# Boots sx_server in the background with a script that loads
# http_server.erl and calls http_server:start/1 on a high port,
# then drives the running server with curl from this shell to
# verify the request → marshaling → route → marshaling → HTTP
# response chain end-to-end.
#
# Boot timing: ~10s for all `lib/erlang/*.sx` loads + module
# compile + spawn + Unix.bind. We hold the server's stdin open
# via `(cat file; sleep 60) | sx_server` so EOF doesn't trigger
# exit(0) before the listener finishes binding.
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
PORT=51820
VERBOSE="${1:-}"
PASS=0; FAIL=0; ERRORS=""
EPOCH_FILE=$(mktemp)
LOG_FILE=$(mktemp)
cleanup() {
if [ -n "${SXPID:-}" ]; then
kill -KILL "$SXPID" 2>/dev/null || true
wait "$SXPID" 2>/dev/null || true
fi
if [ -n "${HOLDPID:-}" ]; then
kill -KILL "$HOLDPID" 2>/dev/null || true
wait "$HOLDPID" 2>/dev/null || true
fi
rm -f "$EPOCH_FILE" "$LOG_FILE"
}
trap cleanup EXIT
cat > "$EPOCH_FILE" <<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")
(epoch 2)
(eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)")
(epoch 3)
(eval "(erlang-eval-ast \"http_server:start($PORT)\")")
EPOCHS
# Run sx_server with stdin held open via a long-running background
# `sleep` so EOF doesn't trigger exit(0) before the listener binds
# and the test finishes curling. Use a FIFO so we can capture both
# the holder process's PID and sx_server's PID explicitly — bash
# only captures the rightmost pipe stage with $!.
FIFO=$(mktemp -u)
mkfifo "$FIFO"
( cat "$EPOCH_FILE"; sleep 120 ) > "$FIFO" &
HOLDPID=$!
"$SX_SERVER" < "$FIFO" > "$LOG_FILE" 2>&1 &
SXPID=$!
rm -f "$FIFO" # both ends still hold open via the running procs
# Wait for the listener to bind (up to ~30s — boot takes ~10s).
BOUND=""
for i in $(seq 1 60); do
if (exec 3<>/dev/tcp/127.0.0.1/$PORT) 2>/dev/null; then
exec 3<&-; exec 3>&-
BOUND="yes"
break
fi
sleep 0.5
done
if [ -z "$BOUND" ]; then
echo "FAIL: listener never bound on port $PORT"
if [ "$VERBOSE" = "-v" ]; then
echo "--- sx_server output ---"
cat "$LOG_FILE"
echo "---"
fi
exit 1
fi
check_http() {
local desc="$1" method="$2" path="$3" auth="$4" expected_status="$5" expected_body_substr="$6"
local args=()
args+=(-s -o /tmp/http_body.out -w "%{http_code}")
args+=(-X "$method")
if [ -n "$auth" ]; then
args+=(-H "Authorization: $auth")
fi
if [ "$method" = "POST" ]; then
args+=(-d "")
fi
args+=("http://127.0.0.1:$PORT$path")
local code
code=$(curl "${args[@]}" 2>/dev/null || echo "000")
local body
body=$(cat /tmp/http_body.out 2>/dev/null || echo "")
local pass=1
if [ "$code" != "$expected_status" ]; then pass=0; fi
if [ -n "$expected_body_substr" ] && ! echo "$body" | grep -qF -- "$expected_body_substr"; then pass=0; fi
if [ $pass -eq 1 ]; then
PASS=$((PASS+1))
[ "$VERBOSE" = "-v" ] && echo " ok $desc ($code)"
else
FAIL=$((FAIL+1))
ERRORS+=" FAIL [$desc] code=$code body=$body
"
fi
}
check_http "GET / -> 200" GET / "" 200 ""
check_http "GET capabilities -> 200" GET /.well-known/sx-capabilities "" 200 "kernel:"
check_http "GET unknown -> 404" GET /no-such-path "" 404 ""
check_http "POST /activity no bearer -> 401" POST /activity "" 401 ""
check_http "POST /activity bad bearer -> 401" POST /activity "Bearer wrong" 401 ""
TOTAL=$((PASS+FAIL))
if [ $FAIL -eq 0 ]; then
echo "ok $PASS/$TOTAL http_server_tcp tests passed (port $PORT)"
else
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
echo "$ERRORS"
if [ "$VERBOSE" = "-v" ]; then
echo "--- sx_server output (last 30 lines) ---"
tail -30 "$LOG_FILE"
echo "---"
fi
fi
[ $FAIL -eq 0 ]