#!/usr/bin/env bash # next/tests/discovery_fetch.sh — m2 Step 10c acceptance test. # # Two halves: # (a) http_server side: the new actor_doc Accept format negotiates # to a term_codec-encoded peer-actor-state proplist served # from `nx_kernel:actor_state/1`. Verified via http_server:route # in-process. # (b) discovery_fetch closure: builds the FetchFn that # peer_actors:lookup_or_fetch_srv/2 expects, GETs the actor # doc via httpc:request/4, decodes the body, returns the AS # proplist. Verified end-to-end against a background # `python3 -m http.server`-style stub that returns hand-crafted # term_codec bytes (so we exercise the wire, not just the # in-process route). 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="" # ── live stub server ───────────────────────────────────────── # Python script that: # GET /actors/alice -> 200 with term_codec-encoded AS # (built in Python: matches term_codec # netstring format spelled out in # next/kernel/term_codec.erl). # GET /actors/missing -> 404 PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()') SRVROOT=$(mktemp -d) PYSRV="$SRVROOT/srv.py" cat > "$PYSRV" <<'PY' import sys, http.server, socketserver PORT = int(sys.argv[1]) # term_codec encoding (mirror of next/kernel/term_codec.erl). def enc_atom(s): b = s.encode() return f"a{len(b)}:".encode() + b def enc_int(n): s = str(n).encode() return f"i{len(s)}:".encode() + s def enc_bin(b): return f"b{len(b)}:".encode() + b def enc_list(items): payload = b"".join(items) # term_codec uses ELEMENT COUNT (not byte length) for list/tuple # headers — see encode/1 in next/kernel/term_codec.erl. return f"l{len(items)}:".encode() + payload def enc_tuple(items): payload = b"".join(items) return f"t{len(items)}:".encode() + payload def enc_nil(): return b"l0:" # {public_keys, [[{id, k1}, {created, 0}, {value, <<1,2,3,4>>}]]} KEY = enc_list([ enc_tuple([enc_atom("id"), enc_atom("k1")]), enc_tuple([enc_atom("created"), enc_int(0)]), enc_tuple([enc_atom("value"), enc_bin(bytes([1,2,3,4]))]), ]) PROPLIST = enc_list([ enc_tuple([enc_atom("public_keys"), enc_list([KEY])]), ]) class H(http.server.BaseHTTPRequestHandler): def do_GET(self): if self.path == "/actors/alice": self.send_response(200) self.send_header('content-type','application/vnd.fed-sx.actor-doc') self.send_header('content-length', str(len(PROPLIST))) self.end_headers() self.wfile.write(PROPLIST) else: self.send_response(404); self.end_headers(); self.wfile.write(b'not found') def log_message(self, fmt, *args): pass with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv: srv.serve_forever() PY python3 "$PYSRV" "$PORT" >/dev/null 2>&1 & SRV_PID=$! TMPFILE=$(mktemp) trap "rm -rf $SRVROOT $TMPFILE; kill $SRV_PID 2>/dev/null || true" EXIT for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do if curl -fsS "http://127.0.0.1:$PORT/actors/alice" >/dev/null 2>&1; then break; fi sleep 0.2 done bytes_of() { python3 -c "import sys; print(','.join(str(b) for b in sys.argv[1].encode()))" "$1"; } URL_BASE_BYTES=$(bytes_of "http://127.0.0.1:$PORT") 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") (epoch 2) (eval "(er-load-gen-server!)") (epoch 3) (eval "(get (erlang-load-module (file-read \"next/kernel/envelope.erl\")) :name)") (epoch 4) (eval "(get (erlang-load-module (file-read \"next/kernel/log.erl\")) :name)") (epoch 5) (eval "(get (erlang-load-module (file-read \"next/kernel/pipeline.erl\")) :name)") (epoch 6) (eval "(get (erlang-load-module (file-read \"next/kernel/term_codec.erl\")) :name)") (epoch 7) (eval "(get (erlang-load-module (file-read \"next/kernel/outbox.erl\")) :name)") (epoch 8) (eval "(get (erlang-load-module (file-read \"next/kernel/peer_actors.erl\")) :name)") (epoch 9) (eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)") (epoch 10) (eval "(get (erlang-load-module (file-read \"next/kernel/http_server.erl\")) :name)") (epoch 11) (eval "(get (erlang-load-module (file-read \"next/kernel/dispatch_http.erl\")) :name)") (epoch 12) (eval "(get (erlang-load-module (file-read \"next/kernel/discovery_fetch.erl\")) :name)") ;; (a) http_server side: actor_doc Accept negotiates to actor_doc (epoch 20) (eval "(get (erlang-eval-ast \"http_server:accept_format(<<97,112,112,108,105,99,97,116,105,111,110,47,118,110,100,46,102,101,100,45,115,120,46,97,99,116,111,114,45,100,111,99>>) =:= actor_doc\") :name)") ;; (a) actor_doc_response_for/3 with kernel + actor returns 200 + ;; term_codec body; decoded body has :public_keys. Inline SETUP ;; per epoch because separate (eval ...) calls share gen_server ;; state but not Erlang locals, and we need fresh kernel-aware ;; assertions even though the previous epoch's nx_kernel persists. (epoch 21) (eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), Cfg = [{kernel, nx_kernel}], R = http_server:actor_doc_response_for(<<97,108,105,99,101>>, actor_doc, Cfg), {ok, S} = envelope:get_field(status, R), S =:= 200\") :name)") ;; (a) body decodes to a proplist with :public_keys (epoch 22) (eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), R = http_server:actor_doc_response_for(<<97,108,105,99,101>>, actor_doc, [{kernel, nx_kernel}]), {ok, Body} = envelope:get_field(body, R), {ok, AS, _} = term_codec:decode(Body), case envelope:get_field(public_keys, AS) of {ok, [_|_]} -> true; _ -> false end\") :name)") ;; (a) unknown actor -> 404 (epoch 23) (eval "(get (erlang-eval-ast \"AK = <<1,2,3,4>>, AKS = [{key_id,k1},{algorithm,ed25519},{value,AK}], AAS = [{public_keys,[[{id,k1},{created,0},{value,AK}]]}], nx_kernel:start_link(alice, AKS, AAS), R = http_server:actor_doc_response_for(<<109,105,115,115,105,110,103>>, actor_doc, [{kernel, nx_kernel}]), {ok, S} = envelope:get_field(status, R), S =:= 404\") :name)") ;; (b) discovery_fetch:actor_doc_url builds /actors/alice (epoch 30) (eval "(get (erlang-eval-ast \"U = discovery_fetch:actor_doc_url(<<__URL_BASE__>>, alice), U =:= <<__URL_BASE__,47,97,99,116,111,114,115,47,97,108,105,99,101>>\") :name)") ;; (b) discovery_fetch:fetch live -> {ok, AS} with :public_keys (epoch 31) (eval "(get (erlang-eval-ast \"R = discovery_fetch:fetch(<<__URL_BASE__,47,97,99,116,111,114,115,47,97,108,105,99,101>>, []), case R of {ok, AS} -> case envelope:get_field(public_keys, AS) of {ok, [_|_]} -> true; _ -> false end; _ -> false end\") :name)") ;; (b) closure produced by make_fetch_fn dispatches ok (epoch 32) (eval "(get (erlang-eval-ast \"Fn = discovery_fetch:make_fetch_fn([{peer_url, [{alice, <<__URL_BASE__>>}]}]), case Fn(alice) of {ok, AS} -> case envelope:get_field(public_keys, AS) of {ok, [_|_]} -> true; _ -> false end; _ -> false end\") :name)") ;; (b) closure on missing peer -> {error, no_peer_url} (epoch 33) (eval "(get (erlang-eval-ast \"Fn = discovery_fetch:make_fetch_fn([{peer_url, []}]), case Fn(alice) of {error, no_peer_url} -> true; _ -> false end\") :name)") ;; (b) closure GETs 404 path -> {error, {status, 404}} (epoch 34) (eval "(get (erlang-eval-ast \"R = discovery_fetch:fetch(<<__URL_BASE__,47,97,99,116,111,114,115,47,109,105,115,115,105,110,103>>, []), case R of {error, {status, 404}} -> true; _ -> false end\") :name)") ;; (b) lookup_or_fetch on cache miss writes the result back (epoch 35) (eval "(get (erlang-eval-ast \"Fn = discovery_fetch:make_fetch_fn([{peer_url, [{alice, <<__URL_BASE__>>}]}]), {R, NewState} = case peer_actors:lookup_or_fetch(alice, Fn, peer_actors:new()) of {ok, _AS, S} -> {ok, S}; {error, R0, S} -> {error, S} end, R =:= ok andalso peer_actors:peers(NewState) =:= [alice]\") :name)") EPOCHS sed -i "s|__URL_BASE__|${URL_BASE_BYTES}|g" "$TMPFILE" OUTPUT=$(timeout 360 "$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 12 "discovery_fetch loaded" "discovery_fetch" check 20 "actor_doc Accept negotiates" "true" check 21 "actor_doc /3 with kernel -> 200" "true" check 22 "body decodes to proplist w/ :public_keys" "true" check 23 "unknown actor -> 404" "true" check 30 "actor_doc_url builds /actors/X" "true" check 31 "fetch live -> {ok, AS}" "true" check 32 "closure -> {ok, AS}" "true" check 33 "closure on missing peer -> no_peer_url" "true" check 34 "closure on 404 -> {status, 404}" "true" check 35 "lookup_or_fetch caches result" "true" TOTAL=$((PASS+FAIL)) if [ $FAIL -eq 0 ]; then echo "ok $PASS/$TOTAL next/tests/discovery_fetch.sh passed" else echo "FAIL $PASS/$TOTAL passed, $FAIL failed:" echo "$ERRORS" fi [ $FAIL -eq 0 ]