Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 49s
Closes Step 10 (10a discovery + 10b webfinger + 10c fetch). New
next/kernel/discovery_fetch.erl produces a 1-arity FetchFn closure
suitable for peer_actors:lookup_or_fetch_srv/2, completing the
discovery half that Step 5c's peer_actors cache stubbed out.
discovery_fetch API:
make_fetch_fn(Cfg) -> fun((PeerId) -> {ok, AS} | {error, _})
fetch(Url, Cfg) -> {ok, AS} | {error, _}
actor_doc_url(BaseUrl, PeerAtom) -> <Base>/actors/<peer>
accept_header/0 -> <<"application/vnd.fed-sx.actor-doc">>
decode_body(Body) -> {ok, AS} | {error, bad_actor_doc}
Closure GETs <base>/actors/<peer> via the Step 8e BIF with
Accept = application/vnd.fed-sx.actor-doc, decodes the response
body via term_codec:decode/1, returns the peer-actor-state
proplist (currently [{public_keys, [...]}]) in the shape
envelope:verify_signature consumes.
Cfg reuses dispatch_http's :peer_url / :peer_url_fn resolution so
a single Cfg threads through both delivery (8f) and discovery (10c).
Server side: http_server.erl extended to serve the same MIME.
- accept_format/1 matches application/vnd.fed-sx.actor-doc first
via the new actor_doc_prefix/0 — content negotiation atom is
`actor_doc`.
- content_type_for(actor_doc) emits the MIME on outbound.
- actor_doc_response_for/3 kernel-aware arm: with kernel + actor
-> 200 + term_codec:encode of nx_kernel:state_for/1 result.
Unknown actor -> not_found_response/0. Other formats fall
through to the existing /2 stub variants.
- actor_get/3 route dispatch threads Cfg to the /3 arm.
Port quirks documented:
* This Erlang doesn't support Mod:Fun(X) dispatch on a variable
module — kernel_actor_state/2 hardcodes nx_kernel; the Cfg
:kernel field is just a "no kernel wired" -> nil flag.
* nx_kernel:actor_state/1 is the LEGACY single-bucket accessor
that takes State (not ActorId); the server-side variant we
want is state_for/1 (gen_server:call wrapper). Easy mismatch,
documented in the comment.
Outcome mapping:
2xx + decodable body -> {ok, AS}
2xx + bad body -> {error, bad_actor_doc}
non-2xx -> {error, {status, N}}
resolver miss -> {error, no_peer_url}
transport -> {error, Reason} (BIF re-raises)
Test: next/tests/discovery_fetch.sh 11/11
Server side (in-process via http_server:actor_doc_response_for):
- Accept negotiation
- kernel + actor -> 200 + decodable body w/ :public_keys
- unknown actor -> 404
Closure side (live HTTP against background python stub returning
hand-crafted term_codec bytes):
- URL construction <base>/actors/X
- fetch live -> {ok, AS}
- make_fetch_fn closure -> {ok, AS} via static :peer_url map
- missing peer -> {error, no_peer_url}
- 404 path -> {error, {status, 404}}
- peer_actors:lookup_or_fetch/3 caches the result
Test setup note: Python term_codec encoder uses ELEMENT COUNT
(not byte length) for l/t headers — see encode/1 in term_codec.erl
which does integer_to_list(length(T)). Easy bug, documented in the
test's python source.
No-regression gates green: Erlang conformance 761/761,
httpc_request 10/10, dispatch_http 10/10, http_listen_bif 5/5,
peer_actors 19/19, discovery 12/12, http_accept 13/13,
http_actors 13/13.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
225 lines
10 KiB
Bash
Executable File
225 lines
10 KiB
Bash
Executable File
#!/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 <base>/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="<no output for epoch $epoch>"
|
|
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 ]
|