Compare commits
180 Commits
a76d072d3f
...
loops/fed-
| Author | SHA1 | Date | |
|---|---|---|---|
| 4da2a98c30 | |||
| 779e53b2a8 | |||
| d09c0048c7 | |||
| 3dbb3e318a | |||
| 29e4234b14 | |||
| cd0de8cb34 | |||
| 03c32cda5f | |||
| 600d292ba2 | |||
| 1d771aedea | |||
| 136deb1daf | |||
| eafb687b53 | |||
| 8d33d02f92 | |||
| 9a204e84ab | |||
| 57684c4589 | |||
| bd2c61367d | |||
| 070986913d | |||
| 3629b2923f | |||
| 9621599606 | |||
| b2b61a0112 | |||
| 80f6fc9279 | |||
| aa27d903ac | |||
| ff024d1b5d | |||
| 8ba3584556 | |||
| 8bf2b45cf9 | |||
| dda967e060 | |||
| bf4e034c4e | |||
| c6b4920074 | |||
| 536473cd68 | |||
| 02c1f0f979 | |||
| 086c576d48 | |||
| ee8a396ccd | |||
| 1d83120918 | |||
| e890380a1a | |||
| 6231a82be0 | |||
| d36fe4ee97 | |||
| d481af5791 | |||
| d103ecb863 | |||
| bc4b23cc62 | |||
| a23a2eb95a | |||
| 6cfb1cb2d3 | |||
| e04a65d400 | |||
| 271632c923 | |||
| 0b8772ec69 | |||
| 238a1fbea0 | |||
| 1fd85e10e6 | |||
| bcfbd9a528 | |||
| 0c44a10c8f | |||
| 089d1445a1 | |||
| 6a9bd054c7 | |||
| 9b04769a27 | |||
| 7ea9d04564 | |||
| 78eae9ef12 | |||
| 7267b83b08 | |||
| 31ff1e6a3f | |||
| 0f85bd963a | |||
| e1336986cd | |||
| ed9f180d12 | |||
| 897449cb35 | |||
| 595c15a3fb | |||
| 6d7f0a3f15 | |||
| 076b8ae7f7 | |||
| 4852cca9eb | |||
| 3d80bd8ce6 | |||
| 24e3bf53b0 | |||
| 24763c5199 | |||
| 004a88c03c | |||
| e8ca0590a3 | |||
| 559ed68907 | |||
| 1496136d12 | |||
| 5940b98878 | |||
| 6137904368 | |||
| 2a14b37c6c | |||
| dd7b7d7a2d | |||
| 1aaede4272 | |||
| 3c945b9104 | |||
| fa064093f5 | |||
| cd7693d443 | |||
| 285dd64dc2 | |||
| 05100ef050 | |||
| ccceb4a0b3 | |||
| e9a905eb5f | |||
| f2aa294f00 | |||
| 212bf53a03 | |||
| 2aeab806fb | |||
| a4905a3e71 | |||
| d15f4d229e | |||
| b45ea2aa16 | |||
| 81efa1d8f0 | |||
| 1ea47681b2 | |||
| c91683b885 | |||
| 4956a6d8ae | |||
| c5481d06aa | |||
| 6e12f539fd | |||
| 8c592c41b8 | |||
| b7f7915c2a | |||
| 460257f2bb | |||
| 9cb002c856 | |||
| aa6b01f430 | |||
| 1aab9eff7d | |||
| d1a2ebd709 | |||
| 203a3a3c67 | |||
| 73a1a55572 | |||
| ae5df5cfa1 | |||
| 5d7b167a93 | |||
| cfdb9cd875 | |||
| 4c0295cdff | |||
| b308ddb9b0 | |||
| 28168b16aa | |||
| ab159dface | |||
| 53b4a4c1fd | |||
| 65dfdd0ba4 | |||
| e11e8b941f | |||
| 9cbf14fe8c | |||
| 11ed4ddf27 | |||
| abde5fbac1 | |||
| b7fcd17e6e | |||
| 89ce7b857d | |||
| 4591ac530b | |||
| 250d0511c0 | |||
| 380bc69f94 | |||
| 77f17cc796 | |||
| 4548461bfc | |||
| 7d9dddcc80 | |||
| 36be6bf44b | |||
| c352d94cc6 | |||
| 857fae1331 | |||
| f8fc04840a | |||
| 76d1e9f53a | |||
| d8b57784fe | |||
| bcaaa11916 | |||
| 451bd4be62 | |||
| 19932a42a9 | |||
| 3629dd96a9 | |||
| a341041627 | |||
| b073a82b33 | |||
| 7996bcdacf | |||
| 3b6241508c | |||
| 5774065341 | |||
| 708b5a2b12 | |||
| e6261c2519 | |||
| 5c7ad01bd1 | |||
| 33725de03b | |||
| 5fd358a7a7 | |||
| 783e0cb5fe | |||
| 72896392c8 | |||
| 12b56afcd3 | |||
| 509197410f | |||
| 76614da154 | |||
| 4dfccc244d | |||
| 58d7445559 | |||
| 4e0a92ec00 | |||
| 85728621b0 | |||
| 715fab86d2 | |||
| f026177e63 | |||
| f3192f7fda | |||
| 57af0f386f | |||
| 8c33a6f8d5 | |||
| cf597f1b5f | |||
| 183bfeebe1 | |||
| 64b7263c5f | |||
| e8a5c2e1ba | |||
| 3efd735283 | |||
| 10623da0b0 | |||
| 528b24a1cd | |||
| 25924d6212 | |||
| 0abf05ed83 | |||
| f6a6865635 | |||
| 6636f9c170 | |||
| 29fd70f17a | |||
| 3d092dd78e | |||
| 2ee5e45515 | |||
| 498d2533d8 | |||
| 925bbd0d42 | |||
| b5e93df82e | |||
| 582baf5bfd | |||
| cd45ebcc7a | |||
| 89a6b30501 | |||
| 0c389d4696 | |||
| 7602ec1a69 | |||
| 2db2d8e9f7 |
@@ -67,6 +67,14 @@ let rec deep_equal a b =
|
|||||||
| NativeFn _, NativeFn _ -> a == b
|
| NativeFn _, NativeFn _ -> a == b
|
||||||
| _ -> false
|
| _ -> false
|
||||||
|
|
||||||
|
(* ====================================================================== *)
|
||||||
|
(* Test extensions for the VM extension registry suite (Phase B) *)
|
||||||
|
(* ====================================================================== *)
|
||||||
|
|
||||||
|
(* Extend the extensible variant from sx_vm_extension.ml so the test
|
||||||
|
extensions below can carry their own private state. *)
|
||||||
|
type Sx_vm_extension.extension_state += TestRegState of int ref
|
||||||
|
|
||||||
(* ====================================================================== *)
|
(* ====================================================================== *)
|
||||||
(* Build evaluator environment with test platform functions *)
|
(* Build evaluator environment with test platform functions *)
|
||||||
(* ====================================================================== *)
|
(* ====================================================================== *)
|
||||||
@@ -1282,7 +1290,827 @@ let run_foundation_tests () =
|
|||||||
let l = { l_params = ["x"]; l_body = Symbol "x"; l_closure = Sx_types.make_env (); l_name = None; l_compiled = None; l_call_count = 0; l_uid = Sx_types.next_lambda_uid () } in
|
let l = { l_params = ["x"]; l_body = Symbol "x"; l_closure = Sx_types.make_env (); l_name = None; l_compiled = None; l_call_count = 0; l_uid = Sx_types.next_lambda_uid () } in
|
||||||
assert_true "is_lambda" (Bool (Sx_types.is_lambda (Lambda l)));
|
assert_true "is_lambda" (Bool (Sx_types.is_lambda (Lambda l)));
|
||||||
ignore (Sx_types.set_lambda_name (Lambda l) "my-fn");
|
ignore (Sx_types.set_lambda_name (Lambda l) "my-fn");
|
||||||
assert_eq "lambda name mutated" (String "my-fn") (lambda_name (Lambda l))
|
assert_eq "lambda name mutated" (String "my-fn") (lambda_name (Lambda l));
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: crypto-sha2\n";
|
||||||
|
(* NIST FIPS 180-4 published vectors. *)
|
||||||
|
assert_eq "sha256 empty"
|
||||||
|
(String "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||||
|
(call "crypto-sha256" [String ""]);
|
||||||
|
assert_eq "sha256 abc"
|
||||||
|
(String "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")
|
||||||
|
(call "crypto-sha256" [String "abc"]);
|
||||||
|
assert_eq "sha256 896-bit"
|
||||||
|
(String "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1")
|
||||||
|
(call "crypto-sha256"
|
||||||
|
[String "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"]);
|
||||||
|
assert_eq "sha256 1M 'a'"
|
||||||
|
(String "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0")
|
||||||
|
(call "crypto-sha256" [String (String.make 1000000 'a')]);
|
||||||
|
assert_eq "sha512 empty"
|
||||||
|
(String "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e")
|
||||||
|
(call "crypto-sha512" [String ""]);
|
||||||
|
assert_eq "sha512 abc"
|
||||||
|
(String "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f")
|
||||||
|
(call "crypto-sha512" [String "abc"]);
|
||||||
|
assert_eq "sha512 896-bit"
|
||||||
|
(String "8e959b75dae313da8cf4f72814fc143f8f7779c6eb9f7fa17299aeadb6889018501d289e4900f7e4331b99dec4b5433ac7d329eeb6dd26545e96e55b874be909")
|
||||||
|
(call "crypto-sha512"
|
||||||
|
[String ("abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmn"
|
||||||
|
^ "hijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu")]);
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: crypto-sha3\n";
|
||||||
|
(* NIST FIPS 202 published vectors. *)
|
||||||
|
assert_eq "sha3-256 empty"
|
||||||
|
(String "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a")
|
||||||
|
(call "crypto-sha3-256" [String ""]);
|
||||||
|
assert_eq "sha3-256 abc"
|
||||||
|
(String "3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532")
|
||||||
|
(call "crypto-sha3-256" [String "abc"]);
|
||||||
|
assert_eq "sha3-256 896-bit"
|
||||||
|
(String "41c0dba2a9d6240849100376a8235e2c82e1b9998a999e21db32dd97496d3376")
|
||||||
|
(call "crypto-sha3-256"
|
||||||
|
[String "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"]);
|
||||||
|
(* 1600-bit message: 0xa3 * 200 — exercises multi-block absorb (>136B). *)
|
||||||
|
assert_eq "sha3-256 1600-bit 0xa3"
|
||||||
|
(String "79f38adec5c20307a98ef76e8324afbfd46cfd81b22e3973c65fa1bd9de31787")
|
||||||
|
(call "crypto-sha3-256" [String (String.make 200 '\xa3')]);
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: dag-cbor\n";
|
||||||
|
let mkdict pairs =
|
||||||
|
let d = Sx_types.make_dict () in
|
||||||
|
List.iter (fun (k, v) -> Hashtbl.replace d k v) pairs;
|
||||||
|
Dict d
|
||||||
|
in
|
||||||
|
let enc v = call "cbor-encode" [v] in
|
||||||
|
(* RFC 8949 Appendix A — minimal-length deterministic encoding. *)
|
||||||
|
assert_eq "cbor 0" (String "\x00") (enc (Integer 0));
|
||||||
|
assert_eq "cbor 23" (String "\x17") (enc (Integer 23));
|
||||||
|
assert_eq "cbor 24" (String "\x18\x18") (enc (Integer 24));
|
||||||
|
assert_eq "cbor 100" (String "\x18\x64") (enc (Integer 100));
|
||||||
|
assert_eq "cbor 1000" (String "\x19\x03\xe8") (enc (Integer 1000));
|
||||||
|
assert_eq "cbor 1000000"
|
||||||
|
(String "\x1a\x00\x0f\x42\x40") (enc (Integer 1000000));
|
||||||
|
assert_eq "cbor -1" (String "\x20") (enc (Integer (-1)));
|
||||||
|
assert_eq "cbor -100" (String "\x38\x63") (enc (Integer (-100)));
|
||||||
|
assert_eq "cbor -1000" (String "\x39\x03\xe7") (enc (Integer (-1000)));
|
||||||
|
assert_eq "cbor false" (String "\xf4") (enc (Bool false));
|
||||||
|
assert_eq "cbor true" (String "\xf5") (enc (Bool true));
|
||||||
|
assert_eq "cbor null" (String "\xf6") (enc Nil);
|
||||||
|
assert_eq "cbor \"\"" (String "\x60") (enc (String ""));
|
||||||
|
assert_eq "cbor \"a\"" (String "\x61\x61") (enc (String "a"));
|
||||||
|
assert_eq "cbor \"IETF\"" (String "\x64IETF") (enc (String "IETF"));
|
||||||
|
assert_eq "cbor []" (String "\x80") (enc (List []));
|
||||||
|
assert_eq "cbor [1,2,3]"
|
||||||
|
(String "\x83\x01\x02\x03")
|
||||||
|
(enc (List [Integer 1; Integer 2; Integer 3]));
|
||||||
|
assert_eq "cbor [1,[2,3],[4,5]]"
|
||||||
|
(String "\x83\x01\x82\x02\x03\x82\x04\x05")
|
||||||
|
(enc (List [Integer 1;
|
||||||
|
List [Integer 2; Integer 3];
|
||||||
|
List [Integer 4; Integer 5]]));
|
||||||
|
assert_eq "cbor {}" (String "\xa0") (enc (mkdict []));
|
||||||
|
assert_eq "cbor {a:1,b:[2,3]}"
|
||||||
|
(String "\xa2\x61\x61\x01\x61\x62\x82\x02\x03")
|
||||||
|
(enc (mkdict ["a", Integer 1; "b", List [Integer 2; Integer 3]]));
|
||||||
|
assert_eq "cbor {a..e:A..E}"
|
||||||
|
(String "\xa5\x61\x61\x61\x41\x61\x62\x61\x42\x61\x63\x61\x43\x61\x64\x61\x44\x61\x65\x61\x45")
|
||||||
|
(enc (mkdict ["a", String "A"; "b", String "B"; "c", String "C";
|
||||||
|
"d", String "D"; "e", String "E"]));
|
||||||
|
(* Determinism: insertion order + key length must not change bytes.
|
||||||
|
Sort is length-then-bytewise → a, c, bb. *)
|
||||||
|
let d1 = mkdict ["bb", Integer 2; "a", Integer 1; "c", Integer 3] in
|
||||||
|
let d2 = mkdict ["c", Integer 3; "bb", Integer 2; "a", Integer 1] in
|
||||||
|
assert_eq "cbor det order-invariant" (enc d1) (enc d2);
|
||||||
|
assert_eq "cbor det length-then-bytewise"
|
||||||
|
(String "\xa3\x61\x61\x01\x61\x63\x03\x62\x62\x62\x02")
|
||||||
|
(enc d1);
|
||||||
|
(* Round-trip: decode . encode = identity (structural). *)
|
||||||
|
let roundtrip name v =
|
||||||
|
assert_eq ("cbor rt " ^ name) v (call "cbor-decode" [enc v])
|
||||||
|
in
|
||||||
|
roundtrip "int" (Integer 42);
|
||||||
|
roundtrip "neg" (Integer (-99999));
|
||||||
|
roundtrip "str" (String "hello world");
|
||||||
|
roundtrip "bool" (Bool true);
|
||||||
|
roundtrip "nil" Nil;
|
||||||
|
roundtrip "nested"
|
||||||
|
(List [Integer 1; String "x"; List [Bool false; Nil]]);
|
||||||
|
roundtrip "dict"
|
||||||
|
(mkdict ["k", List [Integer 7]; "name", String "z"]);
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: cid\n";
|
||||||
|
let mh_sha256 s = Sx_cid.multihash 0x12 (Sx_cid.unhex (Sx_sha2.sha256_hex s)) in
|
||||||
|
(* Authoritative vectors (independently derived; match well-known
|
||||||
|
IPFS CIDs). raw "abc" and raw "" — codec 0x55. *)
|
||||||
|
assert_eq "cid raw abc"
|
||||||
|
(String "bafkreif2pall7dybz7vecqka3zo24irdwabwdi4wc55jznaq75q7eaavvu")
|
||||||
|
(call "cid-from-bytes" [Integer 0x55; String (mh_sha256 "abc")]);
|
||||||
|
assert_eq "cid raw empty"
|
||||||
|
(String "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku")
|
||||||
|
(call "cid-from-bytes" [Integer 0x55; String (mh_sha256 "")]);
|
||||||
|
(* dag-cbor {} — canonical empty-map CID (sha2-256, codec 0x71). *)
|
||||||
|
assert_eq "cid dag-cbor {}"
|
||||||
|
(String "bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua")
|
||||||
|
(call "cid-from-sx" [mkdict []]);
|
||||||
|
(* Determinism: dict key insertion order must not change the CID. *)
|
||||||
|
let cda = call "cid-from-sx" [mkdict ["b", Integer 2; "a", Integer 1]] in
|
||||||
|
let cdb = call "cid-from-sx" [mkdict ["a", Integer 1; "b", Integer 2]] in
|
||||||
|
assert_eq "cid det order-invariant" cda cdb;
|
||||||
|
assert_true "cid multibase 'b' prefix"
|
||||||
|
(Bool (match call "cid-from-sx" [mkdict []] with
|
||||||
|
| String s -> String.length s > 1 && s.[0] = 'b'
|
||||||
|
| _ -> false));
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: ed25519\n";
|
||||||
|
let hx = Sx_ed25519.unhex in
|
||||||
|
let edv pk msg sg = call "ed25519-verify"
|
||||||
|
[String (hx pk); String (hx msg); String (hx sg)] in
|
||||||
|
(* RFC 8032 §7.1 TEST 1-3 (deterministic; re-derived independently). *)
|
||||||
|
assert_eq "ed25519 RFC T1"
|
||||||
|
(Bool true)
|
||||||
|
(edv "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||||
|
""
|
||||||
|
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b");
|
||||||
|
assert_eq "ed25519 RFC T2"
|
||||||
|
(Bool true)
|
||||||
|
(edv "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
|
||||||
|
"72"
|
||||||
|
"92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00");
|
||||||
|
assert_eq "ed25519 RFC T3"
|
||||||
|
(Bool true)
|
||||||
|
(edv "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"
|
||||||
|
"af82"
|
||||||
|
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a");
|
||||||
|
(* Tampered message -> false. *)
|
||||||
|
assert_eq "ed25519 tampered msg"
|
||||||
|
(Bool false)
|
||||||
|
(edv "fc51cd8e6218a1a38da47ed00230f0580816ed13ba3303ac5deb911548908025"
|
||||||
|
"af83"
|
||||||
|
"6291d657deec24024827e69c3abe01a30ce548a284743a445e3680d7db5ac3ac18ff9b538d16f290ae67f760984dc6594a7c15e9716ed28dc027beceea1ec40a");
|
||||||
|
(* Tampered signature -> false. *)
|
||||||
|
assert_eq "ed25519 tampered sig"
|
||||||
|
(Bool false)
|
||||||
|
(edv "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||||
|
""
|
||||||
|
"f5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b");
|
||||||
|
(* Total: wrong-length pubkey / sig -> false, no exception. *)
|
||||||
|
assert_eq "ed25519 short pubkey"
|
||||||
|
(Bool false)
|
||||||
|
(call "ed25519-verify" [String "abc"; String ""; String (String.make 64 '\000')]);
|
||||||
|
assert_eq "ed25519 short sig"
|
||||||
|
(Bool false)
|
||||||
|
(call "ed25519-verify"
|
||||||
|
[String (hx "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a");
|
||||||
|
String ""; String "short"]);
|
||||||
|
assert_eq "ed25519 non-string args"
|
||||||
|
(Bool false)
|
||||||
|
(call "ed25519-verify" [Integer 1; Integer 2; Integer 3]);
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: rsa-sha256\n";
|
||||||
|
(* Fixed RSA-2048 vector: one-off python-cryptography keygen +
|
||||||
|
PKCS1v15/SHA-256 sign of "fed-sx phase F rsa test". *)
|
||||||
|
let rhx = Sx_rsa.unhex in
|
||||||
|
let spki = rhx "30820122300d06092a864886f70d01010105000382010f003082010a0282010100a117b573480bce5a08b54a98384001df26d062e9173caaee2e3a2d0045c6d16f99b2a1e7fb60763f65f95f8c39ff82c18b8590338042914331db3440a06d2dbe65a2f82c82f37d293f67a8b57a1f9014b55150a093cfee90257ef3b4a215d5ab002579bd92b6fcb3536777d51b639347d01e307ddafb209073dd9b8d6a507157c44c624a19b3b9275931472462870ae02132630159132a85c1c889adfb358b6bbd3760ce3fffe6285964833a10ee436d5bc33dfab7f9ed630a74e9a32e5688f5a7797f7cc839ad2494dd1c4c4a8fab844cd26208794bf2602c16b9d12bde434066d8c0dd2d20489f4070f883bae2b4508ead4a1b80b44c576e9e37bdb5df69f10203010001" in
|
||||||
|
let rmsg = rhx "6665642d73782070686173652046207273612074657374" in
|
||||||
|
let rsig = rhx "5e1593d674ed15c0172546d38efdf1aebd252f4b0c0dfbe1f7996fd569d0bfd9f3e8689ea2b14aa45b5fc3f0a05d4f23c6b02b8820d71f6998ea3b5b0d071bb33142236e388b1226ece3ec447d33b38999f189c37564cf052cf038de94c67b2ddf9a97d5a73554bb88818f615824517209a4083258965adace55658f344104eaa0d5f2f44ea00cfac8754674aade87b40d955cccd1ccd9b7649a08b66ce3bc5dba2de96b3e859488ded3ef9fb3744a1e3495fd14841d8319b3cc08054c729d1c02739ee314eba2b20fac46e463f47eb67183d8455583eca73ba37448164612dd9cd77877135d30d12084c2843f986a5b8ad59c6600f9855b91d7cbdf7c6c4b0e" in
|
||||||
|
let rsav s m g = call "rsa-sha256-verify" [String s; String m; String g] in
|
||||||
|
assert_eq "rsa valid" (Bool true) (rsav spki rmsg rsig);
|
||||||
|
assert_eq "rsa tampered msg" (Bool false)
|
||||||
|
(rsav spki (rmsg ^ "x") rsig);
|
||||||
|
assert_eq "rsa tampered sig" (Bool false)
|
||||||
|
(rsav spki rmsg
|
||||||
|
(rhx "5f1593d674ed15c0172546d38efdf1aebd252f4b0c0dfbe1f7996fd569d0bfd9f3e8689ea2b14aa45b5fc3f0a05d4f23c6b02b8820d71f6998ea3b5b0d071bb33142236e388b1226ece3ec447d33b38999f189c37564cf052cf038de94c67b2ddf9a97d5a73554bb88818f615824517209a4083258965adace55658f344104eaa0d5f2f44ea00cfac8754674aade87b40d955cccd1ccd9b7649a08b66ce3bc5dba2de96b3e859488ded3ef9fb3744a1e3495fd14841d8319b3cc08054c729d1c02739ee314eba2b20fac46e463f47eb67183d8455583eca73ba37448164612dd9cd77877135d30d12084c2843f986a5b8ad59c6600f9855b91d7cbdf7c6c4b0e"));
|
||||||
|
assert_eq "rsa garbage spki" (Bool false)
|
||||||
|
(rsav "not der" rmsg rsig);
|
||||||
|
assert_eq "rsa non-string args" (Bool false)
|
||||||
|
(call "rsa-sha256-verify" [Integer 1; Integer 2; Integer 3]);
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: file-list-dir\n";
|
||||||
|
let expect_err nm f =
|
||||||
|
(try ignore (f ());
|
||||||
|
incr fail_count; Printf.printf " FAIL: %s — no error\n" nm
|
||||||
|
with Eval_error _ ->
|
||||||
|
incr pass_count; Printf.printf " PASS: %s\n" nm
|
||||||
|
| _ ->
|
||||||
|
incr fail_count; Printf.printf " FAIL: %s — wrong exn\n" nm)
|
||||||
|
in
|
||||||
|
let tmp = Filename.temp_file "fld" "" in
|
||||||
|
Sys.remove tmp; Unix.mkdir tmp 0o755;
|
||||||
|
let touch n = let oc = open_out (Filename.concat tmp n) in close_out oc in
|
||||||
|
touch "b.txt"; touch "a.txt"; touch "c.txt";
|
||||||
|
assert_eq "file-list-dir sorted"
|
||||||
|
(List [String "a.txt"; String "b.txt"; String "c.txt"])
|
||||||
|
(call "file-list-dir" [String tmp]);
|
||||||
|
expect_err "file-list-dir missing"
|
||||||
|
(fun () -> call "file-list-dir" [String (Filename.concat tmp "nope")]);
|
||||||
|
expect_err "file-list-dir not-a-dir"
|
||||||
|
(fun () -> call "file-list-dir" [String (Filename.concat tmp "a.txt")]);
|
||||||
|
expect_err "file-list-dir arity"
|
||||||
|
(fun () -> call "file-list-dir" []);
|
||||||
|
(* best-effort cleanup *)
|
||||||
|
(try List.iter (fun n -> Sys.remove (Filename.concat tmp n))
|
||||||
|
["a.txt"; "b.txt"; "c.txt"]; Unix.rmdir tmp
|
||||||
|
with _ -> ());
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: vm-extension-dispatch\n";
|
||||||
|
let make_bc op = ({
|
||||||
|
vc_arity = 0; vc_rest_arity = -1; vc_locals = 0;
|
||||||
|
vc_bytecode = [| op |]; vc_constants = [||];
|
||||||
|
vc_bytecode_list = None; vc_constants_list = None;
|
||||||
|
} : Sx_types.vm_code) in
|
||||||
|
let expect_invalid_opcode label op =
|
||||||
|
let globals = Hashtbl.create 1 in
|
||||||
|
try
|
||||||
|
let _ = Sx_vm.execute_module (make_bc op) globals in
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: %s — expected Invalid_opcode, got a result\n" label
|
||||||
|
with
|
||||||
|
| Sx_vm.Invalid_opcode n when n = op ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: %s\n" label
|
||||||
|
| exn ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: %s — unexpected: %s\n" label (Printexc.to_string exn)
|
||||||
|
in
|
||||||
|
expect_invalid_opcode "opcode 200 raises Invalid_opcode 200" 200;
|
||||||
|
expect_invalid_opcode "opcode 224 raises Invalid_opcode 224" 224;
|
||||||
|
expect_invalid_opcode "opcode 247 raises Invalid_opcode 247" 247;
|
||||||
|
(* Opcode 199 sits just below the extension threshold — should fall to the
|
||||||
|
catch-all (Eval_error), proving the threshold is at 200, not 199. *)
|
||||||
|
let globals = Hashtbl.create 1 in
|
||||||
|
(try
|
||||||
|
let _ = Sx_vm.execute_module (make_bc 199) globals in
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: opcode 199 — expected Eval_error, got a result\n"
|
||||||
|
with
|
||||||
|
| Sx_vm.Invalid_opcode _ ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: opcode 199 routed to extension dispatch (threshold wrong)\n"
|
||||||
|
| Sx_types.Eval_error _ ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: opcode 199 stays in core (catch-all)\n"
|
||||||
|
| exn ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: opcode 199 — unexpected: %s\n" (Printexc.to_string exn));
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: vm-extension-registry\n";
|
||||||
|
(* Sx_vm_extensions self-installs its dispatcher at module init. Reset
|
||||||
|
the registry so prior loaded extensions don't interfere with this
|
||||||
|
test. *)
|
||||||
|
Sx_vm_extensions._reset_for_tests ();
|
||||||
|
let module TestExt : Sx_vm_extension.EXTENSION = struct
|
||||||
|
let name = "test_reg"
|
||||||
|
let init () = TestRegState (ref 0)
|
||||||
|
let opcodes _st = [
|
||||||
|
(210, "test_reg.OP_PUSH_42", (fun vm _frame ->
|
||||||
|
Sx_vm.push vm (Sx_types.Integer 42)));
|
||||||
|
(211, "test_reg.OP_DOUBLE_TOS", (fun vm _frame ->
|
||||||
|
let v = Sx_vm.pop vm in
|
||||||
|
match v with
|
||||||
|
| Sx_types.Integer n -> Sx_vm.push vm (Sx_types.Integer (n * 2))
|
||||||
|
| _ -> failwith "OP_DOUBLE_TOS: not an integer"));
|
||||||
|
]
|
||||||
|
end in
|
||||||
|
Sx_vm_extensions.register (module TestExt);
|
||||||
|
|
||||||
|
(match Sx_vm_extensions.id_of_name "test_reg.OP_PUSH_42" with
|
||||||
|
| Some 210 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: id_of_name resolves opcode\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: id_of_name: got %s\n"
|
||||||
|
(match other with Some n -> string_of_int n | None -> "None"));
|
||||||
|
|
||||||
|
(match Sx_vm_extensions.id_of_name "nonexistent.OP" with
|
||||||
|
| None ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: id_of_name returns None for unknown\n"
|
||||||
|
| Some _ ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: id_of_name should return None for unknown\n");
|
||||||
|
|
||||||
|
(match Sx_vm_extensions.state_of_extension "test_reg" with
|
||||||
|
| Some (TestRegState _) ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: state_of_extension returns extension state\n"
|
||||||
|
| _ ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: state_of_extension lookup\n");
|
||||||
|
|
||||||
|
(match Sx_vm_extensions.state_of_extension "nonexistent" with
|
||||||
|
| None ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: state_of_extension None for unknown\n"
|
||||||
|
| Some _ ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: state_of_extension should be None\n");
|
||||||
|
|
||||||
|
(* End-to-end dispatch through the VM. Bytecode runs OP_PUSH_42 then
|
||||||
|
OP_RETURN (50); execute_module pops the result. *)
|
||||||
|
let make_bc_seq bytes = ({
|
||||||
|
vc_arity = 0; vc_rest_arity = -1; vc_locals = 0;
|
||||||
|
vc_bytecode = bytes; vc_constants = [||];
|
||||||
|
vc_bytecode_list = None; vc_constants_list = None;
|
||||||
|
} : Sx_types.vm_code) in
|
||||||
|
(let globals = Hashtbl.create 1 in
|
||||||
|
try
|
||||||
|
match Sx_vm.execute_module (make_bc_seq [| 210; 50 |]) globals with
|
||||||
|
| Integer 42 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: dispatch routes opcode 210 -> push 42\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: dispatch opcode 210: got %s\n"
|
||||||
|
(Sx_types.inspect other)
|
||||||
|
with exn ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: dispatch opcode 210 raised: %s\n"
|
||||||
|
(Printexc.to_string exn));
|
||||||
|
|
||||||
|
(* Compose two extension opcodes: PUSH_42 then DOUBLE_TOS then RETURN.
|
||||||
|
Verifies that successive extension dispatches share VM state. *)
|
||||||
|
(let globals = Hashtbl.create 1 in
|
||||||
|
try
|
||||||
|
match Sx_vm.execute_module (make_bc_seq [| 210; 211; 50 |]) globals with
|
||||||
|
| Integer 84 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: extension opcodes compose (42 -> 84)\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: composed opcodes: got %s\n"
|
||||||
|
(Sx_types.inspect other)
|
||||||
|
with exn ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: composed opcodes raised: %s\n"
|
||||||
|
(Printexc.to_string exn));
|
||||||
|
|
||||||
|
(* Duplicate opcode-id detection. *)
|
||||||
|
let module DupExt : Sx_vm_extension.EXTENSION = struct
|
||||||
|
let name = "dup_check"
|
||||||
|
let init () = TestRegState (ref 0)
|
||||||
|
let opcodes _st = [
|
||||||
|
(210, "dup_check.OP_X", (fun _vm _frame -> ()));
|
||||||
|
]
|
||||||
|
end in
|
||||||
|
(try
|
||||||
|
Sx_vm_extensions.register (module DupExt);
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: duplicate opcode id should have raised\n"
|
||||||
|
with Failure _ ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: duplicate opcode id rejected\n");
|
||||||
|
|
||||||
|
(* Out-of-range opcode-id detection. *)
|
||||||
|
let module OutExt : Sx_vm_extension.EXTENSION = struct
|
||||||
|
let name = "out_of_range"
|
||||||
|
let init () = TestRegState (ref 0)
|
||||||
|
let opcodes _st = [
|
||||||
|
(300, "out_of_range.OP_X", (fun _vm _frame -> ()));
|
||||||
|
]
|
||||||
|
end in
|
||||||
|
(try
|
||||||
|
Sx_vm_extensions.register (module OutExt);
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: out-of-range opcode should have raised\n"
|
||||||
|
with Failure _ ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: out-of-range opcode rejected\n");
|
||||||
|
|
||||||
|
(* Duplicate extension-name detection. *)
|
||||||
|
let module SameNameExt : Sx_vm_extension.EXTENSION = struct
|
||||||
|
let name = "test_reg" (* same as TestExt above *)
|
||||||
|
let init () = TestRegState (ref 0)
|
||||||
|
let opcodes _st = []
|
||||||
|
end in
|
||||||
|
(try
|
||||||
|
Sx_vm_extensions.register (module SameNameExt);
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: duplicate extension name should have raised\n"
|
||||||
|
with Failure _ ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: duplicate extension name rejected\n");
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: extension-opcode-id primitive\n";
|
||||||
|
let prim = Hashtbl.find Sx_primitives.primitives "extension-opcode-id" in
|
||||||
|
|
||||||
|
(* Known opcode (registered by TestExt above). *)
|
||||||
|
(match prim [String "test_reg.OP_PUSH_42"] with
|
||||||
|
| Integer 210 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: primitive returns Integer for registered opcode\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: registered opcode lookup: got %s\n"
|
||||||
|
(Sx_types.inspect other));
|
||||||
|
|
||||||
|
(* Unknown opcode → Nil. *)
|
||||||
|
(match prim [String "nonexistent.OP_X"] with
|
||||||
|
| Nil ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: primitive returns nil for unknown opcode\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: unknown opcode lookup: got %s\n"
|
||||||
|
(Sx_types.inspect other));
|
||||||
|
|
||||||
|
(* Symbol arg also accepted (compilers may pass quoted symbols). *)
|
||||||
|
(match prim [Symbol "test_reg.OP_DOUBLE_TOS"] with
|
||||||
|
| Integer 211 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: primitive accepts Symbol args\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: symbol arg: got %s\n" (Sx_types.inspect other));
|
||||||
|
|
||||||
|
(* Wrong arity / type raises Eval_error. *)
|
||||||
|
(try
|
||||||
|
let _ = prim [] in
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: zero args should have raised\n"
|
||||||
|
with Sx_types.Eval_error _ ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: zero args rejected\n");
|
||||||
|
|
||||||
|
(try
|
||||||
|
let _ = prim [Integer 42] in
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: integer arg should have raised\n"
|
||||||
|
with Sx_types.Eval_error _ ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: integer arg rejected\n");
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: extensions/test_ext (canonical extension)\n";
|
||||||
|
(* Phase D: the real test extension lives at lib/extensions/test_ext.ml.
|
||||||
|
Register it on top of the inline test_reg from earlier suites — the
|
||||||
|
two use disjoint opcode IDs (210/211 vs 220/221) so they coexist. *)
|
||||||
|
Test_ext.register ();
|
||||||
|
|
||||||
|
(* Lookup via the public primitive should now find OP_TEST_PUSH_42. *)
|
||||||
|
(match prim [String "test_ext.OP_TEST_PUSH_42"] with
|
||||||
|
| Integer 220 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: extension-opcode-id finds test_ext.OP_TEST_PUSH_42\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: opcode lookup: got %s\n" (Sx_types.inspect other));
|
||||||
|
|
||||||
|
(* End-to-end: PUSH_42 + DOUBLE_TOS + RETURN. *)
|
||||||
|
(let globals = Hashtbl.create 1 in
|
||||||
|
try
|
||||||
|
match Sx_vm.execute_module (make_bc_seq [| 220; 221; 50 |]) globals with
|
||||||
|
| Integer 84 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: extensions/test_ext bytecode executes (84)\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: test_ext bytecode result: got %s\n"
|
||||||
|
(Sx_types.inspect other)
|
||||||
|
with exn ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: test_ext bytecode raised: %s\n"
|
||||||
|
(Printexc.to_string exn));
|
||||||
|
|
||||||
|
(* Disassembly: opcode_name should resolve 220/221 via the registry,
|
||||||
|
not fall back to UNKNOWN_220 / UNKNOWN_221. disassemble returns a
|
||||||
|
Dict; the instruction list lives at key "bytecode". *)
|
||||||
|
(let code = make_bc_seq [| 220; 221; 50 |] in
|
||||||
|
let dis = Sx_vm.disassemble code in
|
||||||
|
let entries = match dis with
|
||||||
|
| Dict d -> (match Hashtbl.find_opt d "bytecode" with
|
||||||
|
| Some (List es) -> es
|
||||||
|
| _ -> [])
|
||||||
|
| _ -> []
|
||||||
|
in
|
||||||
|
let names = List.filter_map (fun entry -> match entry with
|
||||||
|
| Dict d ->
|
||||||
|
(match Hashtbl.find_opt d "opcode" with
|
||||||
|
| Some (String name) -> Some name
|
||||||
|
| _ -> None)
|
||||||
|
| _ -> None) entries
|
||||||
|
in
|
||||||
|
let has name = List.mem name names in
|
||||||
|
if has "test_ext.OP_TEST_PUSH_42" && has "test_ext.OP_TEST_DOUBLE_TOS" then begin
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: disassemble shows extension opcode names\n"
|
||||||
|
end else begin
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: disassemble names: [%s]\n" (String.concat ", " names)
|
||||||
|
end);
|
||||||
|
|
||||||
|
(* Sanity: opcode_name on an unregistered extension opcode still
|
||||||
|
returns UNKNOWN_n. Pick 230 — out of test_ext's range. *)
|
||||||
|
(match Sx_vm.opcode_name 230 with
|
||||||
|
| "UNKNOWN_230" ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: unregistered ext opcode falls back to UNKNOWN_n\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: opcode_name 230: got %s\n" other);
|
||||||
|
|
||||||
|
(* Per-extension state: invocation_count should reflect the two opcodes
|
||||||
|
that ran in the dispatch test above. *)
|
||||||
|
(match Test_ext.invocation_count () with
|
||||||
|
| Some n when n >= 2 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: extension state recorded %d invocations\n" n
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: invocation_count: %s\n"
|
||||||
|
(match other with Some n -> string_of_int n | None -> "None"));
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: extensions/erlang_ext (Phase 9h)\n";
|
||||||
|
(* Register the Erlang opcode namespace. Disjoint id range (200-217)
|
||||||
|
from test_ext (220/221) so they coexist. *)
|
||||||
|
Erlang_ext.register ();
|
||||||
|
|
||||||
|
(match prim [String "erlang.OP_PATTERN_TUPLE"] with
|
||||||
|
| Integer 222 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: extension-opcode-id erlang.OP_PATTERN_TUPLE = 222\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: erlang.OP_PATTERN_TUPLE: got %s\n"
|
||||||
|
(Sx_types.inspect other));
|
||||||
|
|
||||||
|
(match prim [String "erlang.OP_BIF_IS_TUPLE"] with
|
||||||
|
| Integer 239 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: extension-opcode-id erlang.OP_BIF_IS_TUPLE = 239\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: erlang.OP_BIF_IS_TUPLE: got %s\n"
|
||||||
|
(Sx_types.inspect other));
|
||||||
|
|
||||||
|
(match prim [String "erlang.OP_NONEXISTENT"] with
|
||||||
|
| Nil ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: unknown erlang opcode -> nil\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: unknown erlang opcode: got %s\n"
|
||||||
|
(Sx_types.inspect other));
|
||||||
|
|
||||||
|
(* Phase 10b vertical slice: erlang.OP_BIF_LENGTH (230) is a REAL
|
||||||
|
handler. Build [CONST 0; OP_BIF_LENGTH; RETURN] with an Erlang
|
||||||
|
list [1,2,3] in the constant pool; expect Integer 3. Proves the
|
||||||
|
full path: bytecode -> Sx_vm extension fallthrough -> erlang_ext
|
||||||
|
handler -> correct stack result. *)
|
||||||
|
(let mk_dict kvs =
|
||||||
|
let h = Hashtbl.create 4 in
|
||||||
|
List.iter (fun (k, v) -> Hashtbl.replace h k v) kvs;
|
||||||
|
Sx_types.Dict h in
|
||||||
|
let er_nil = mk_dict [("tag", Sx_types.String "nil")] in
|
||||||
|
let er_cons hd tl =
|
||||||
|
mk_dict [("tag", Sx_types.String "cons");
|
||||||
|
("head", hd); ("tail", tl)] in
|
||||||
|
let lst = er_cons (Sx_types.Integer 1)
|
||||||
|
(er_cons (Sx_types.Integer 2)
|
||||||
|
(er_cons (Sx_types.Integer 3) er_nil)) in
|
||||||
|
let code = ({
|
||||||
|
vc_arity = 0; vc_rest_arity = -1; vc_locals = 0;
|
||||||
|
vc_bytecode = [| 1; 0; 0; 230; 50 |];
|
||||||
|
vc_constants = [| lst |];
|
||||||
|
vc_bytecode_list = None; vc_constants_list = None;
|
||||||
|
} : Sx_types.vm_code) in
|
||||||
|
let globals = Hashtbl.create 1 in
|
||||||
|
try
|
||||||
|
match Sx_vm.execute_module code globals with
|
||||||
|
| Integer 3 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: erlang.OP_BIF_LENGTH [1,2,3] -> 3 (real handler, end-to-end)\n"
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: OP_BIF_LENGTH result: got %s\n"
|
||||||
|
(Sx_types.inspect other)
|
||||||
|
with exn ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: OP_BIF_LENGTH raised: %s\n"
|
||||||
|
(Printexc.to_string exn));
|
||||||
|
|
||||||
|
(* More real handlers (Phase 10b batch): build a list/tuple constant
|
||||||
|
and exercise HD/TL/TUPLE_SIZE/IS_* end-to-end through the VM. *)
|
||||||
|
(let mk_dict kvs =
|
||||||
|
let h = Hashtbl.create 4 in
|
||||||
|
List.iter (fun (k, v) -> Hashtbl.replace h k v) kvs;
|
||||||
|
Sx_types.Dict h in
|
||||||
|
let er_nil = mk_dict [("tag", Sx_types.String "nil")] in
|
||||||
|
let er_cons hd tl = mk_dict [("tag", Sx_types.String "cons");
|
||||||
|
("head", hd); ("tail", tl)] in
|
||||||
|
let er_tuple es = mk_dict [("tag", Sx_types.String "tuple");
|
||||||
|
("elements", Sx_types.List es)] in
|
||||||
|
let er_atom nm = mk_dict [("tag", Sx_types.String "atom");
|
||||||
|
("name", Sx_types.String nm)] in
|
||||||
|
let lst3 = er_cons (Sx_types.Integer 7)
|
||||||
|
(er_cons (Sx_types.Integer 8)
|
||||||
|
(er_cons (Sx_types.Integer 9) er_nil)) in
|
||||||
|
let tup3 = er_tuple [Sx_types.Integer 1; Sx_types.Integer 2;
|
||||||
|
Sx_types.Integer 3] in
|
||||||
|
let run consts bc =
|
||||||
|
let code = ({
|
||||||
|
vc_arity = 0; vc_rest_arity = -1; vc_locals = 0;
|
||||||
|
vc_bytecode = bc; vc_constants = consts;
|
||||||
|
vc_bytecode_list = None; vc_constants_list = None;
|
||||||
|
} : Sx_types.vm_code) in
|
||||||
|
Sx_vm.execute_module code (Hashtbl.create 1) in
|
||||||
|
let nm = function
|
||||||
|
| Sx_types.Dict d ->
|
||||||
|
(match Hashtbl.find_opt d "name" with
|
||||||
|
| Some (Sx_types.String s) -> s | _ -> "?")
|
||||||
|
| _ -> "?" in
|
||||||
|
let check label want got =
|
||||||
|
if got = want then begin
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: %s\n" label
|
||||||
|
end else begin
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: %s: got %s\n" label (Sx_types.inspect got)
|
||||||
|
end in
|
||||||
|
(* HD [7,8,9] -> 7 *)
|
||||||
|
check "OP_BIF_HD [7,8,9] -> 7" (Sx_types.Integer 7)
|
||||||
|
(run [| lst3 |] [| 1;0;0; 231; 50 |]);
|
||||||
|
(* TL [7,8,9] -> [8,9], check its HD = 8 *)
|
||||||
|
check "OP_BIF_TL then HD -> 8" (Sx_types.Integer 8)
|
||||||
|
(run [| lst3 |] [| 1;0;0; 232; 231; 50 |]);
|
||||||
|
(* TUPLE_SIZE {1,2,3} -> 3 *)
|
||||||
|
check "OP_BIF_TUPLE_SIZE {1,2,3} -> 3" (Sx_types.Integer 3)
|
||||||
|
(run [| tup3 |] [| 1;0;0; 234; 50 |]);
|
||||||
|
(* IS_INTEGER 42 -> true ; IS_INTEGER [..] -> false *)
|
||||||
|
(match run [| Sx_types.Integer 42 |] [| 1;0;0; 236; 50 |] with
|
||||||
|
| v when nm v = "true" ->
|
||||||
|
incr pass_count; Printf.printf " PASS: OP_BIF_IS_INTEGER 42 -> true\n"
|
||||||
|
| v -> incr fail_count;
|
||||||
|
Printf.printf " FAIL: IS_INTEGER 42: got %s\n" (Sx_types.inspect v));
|
||||||
|
(match run [| lst3 |] [| 1;0;0; 236; 50 |] with
|
||||||
|
| v when nm v = "false" ->
|
||||||
|
incr pass_count; Printf.printf " PASS: OP_BIF_IS_INTEGER list -> false\n"
|
||||||
|
| v -> incr fail_count;
|
||||||
|
Printf.printf " FAIL: IS_INTEGER list: got %s\n" (Sx_types.inspect v));
|
||||||
|
(* IS_ATOM atom -> true ; IS_LIST nil -> true ; IS_TUPLE tuple -> true *)
|
||||||
|
(match run [| er_atom "ok" |] [| 1;0;0; 237; 50 |] with
|
||||||
|
| v when nm v = "true" ->
|
||||||
|
incr pass_count; Printf.printf " PASS: OP_BIF_IS_ATOM ok -> true\n"
|
||||||
|
| v -> incr fail_count;
|
||||||
|
Printf.printf " FAIL: IS_ATOM: got %s\n" (Sx_types.inspect v));
|
||||||
|
(match run [| er_nil |] [| 1;0;0; 238; 50 |] with
|
||||||
|
| v when nm v = "true" ->
|
||||||
|
incr pass_count; Printf.printf " PASS: OP_BIF_IS_LIST nil -> true\n"
|
||||||
|
| v -> incr fail_count;
|
||||||
|
Printf.printf " FAIL: IS_LIST nil: got %s\n" (Sx_types.inspect v));
|
||||||
|
(match run [| tup3 |] [| 1;0;0; 239; 50 |] with
|
||||||
|
| v when nm v = "true" ->
|
||||||
|
incr pass_count; Printf.printf " PASS: OP_BIF_IS_TUPLE {..} -> true\n"
|
||||||
|
| v -> incr fail_count;
|
||||||
|
Printf.printf " FAIL: IS_TUPLE: got %s\n" (Sx_types.inspect v));
|
||||||
|
(match run [| tup3 |] [| 1;0;0; 238; 50 |] with
|
||||||
|
| v when nm v = "false" ->
|
||||||
|
incr pass_count; Printf.printf " PASS: OP_BIF_IS_LIST tuple -> false\n"
|
||||||
|
| v -> incr fail_count;
|
||||||
|
Printf.printf " FAIL: IS_LIST tuple: got %s\n" (Sx_types.inspect v));
|
||||||
|
(* ELEMENT: element(2, {1,2,3}) -> 2. Calling convention: push
|
||||||
|
Index then Tuple; opcode pops Tuple (TOS) then Index. *)
|
||||||
|
check "OP_BIF_ELEMENT element(2,{1,2,3}) -> 2" (Sx_types.Integer 2)
|
||||||
|
(run [| Sx_types.Integer 2; tup3 |] [| 1;0;0; 1;1;0; 233; 50 |]);
|
||||||
|
check "OP_BIF_ELEMENT element(1,{1,2,3}) -> 1" (Sx_types.Integer 1)
|
||||||
|
(run [| Sx_types.Integer 1; tup3 |] [| 1;0;0; 1;1;0; 233; 50 |]);
|
||||||
|
(* ELEMENT out of range raises *)
|
||||||
|
(let raised =
|
||||||
|
(try ignore (run [| Sx_types.Integer 9; tup3 |]
|
||||||
|
[| 1;0;0; 1;1;0; 233; 50 |]); false
|
||||||
|
with Sx_types.Eval_error _ -> true) in
|
||||||
|
if raised then begin
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: OP_BIF_ELEMENT out-of-range raises\n"
|
||||||
|
end else begin
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: OP_BIF_ELEMENT out-of-range should raise\n"
|
||||||
|
end);
|
||||||
|
(* LISTS_REVERSE [7,8,9] -> [9,8,7]; verify HD = 9 then HD of TL = 8 *)
|
||||||
|
check "OP_BIF_LISTS_REVERSE then HD -> 9" (Sx_types.Integer 9)
|
||||||
|
(run [| lst3 |] [| 1;0;0; 235; 231; 50 |]);
|
||||||
|
check "OP_BIF_LISTS_REVERSE then TL,HD -> 8" (Sx_types.Integer 8)
|
||||||
|
(run [| lst3 |] [| 1;0;0; 235; 232; 231; 50 |]);
|
||||||
|
(* reverse preserves length *)
|
||||||
|
check "OP_BIF_LISTS_REVERSE then LENGTH -> 3" (Sx_types.Integer 3)
|
||||||
|
(run [| lst3 |] [| 1;0;0; 235; 230; 50 |]));
|
||||||
|
|
||||||
|
(* A still-stubbed opcode (222 = erlang.OP_PATTERN_TUPLE) raises the
|
||||||
|
not-wired Eval_error — confirms the honest-failure path remains
|
||||||
|
for opcodes whose real handlers haven't landed. *)
|
||||||
|
(let globals = Hashtbl.create 1 in
|
||||||
|
try
|
||||||
|
ignore (Sx_vm.execute_module (make_bc_seq [| 222; 50 |]) globals);
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: erlang.OP_PATTERN_TUPLE dispatch should have raised\n"
|
||||||
|
with
|
||||||
|
| Sx_types.Eval_error msg
|
||||||
|
when (let needle = "not yet wired" in
|
||||||
|
let nl = String.length needle and ml = String.length msg in
|
||||||
|
let rec scan i =
|
||||||
|
if i + nl > ml then false
|
||||||
|
else if String.sub msg i nl = needle then true
|
||||||
|
else scan (i + 1)
|
||||||
|
in scan 0) ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: erlang opcode dispatch raises not-wired error\n"
|
||||||
|
| exn ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: unexpected exn: %s\n" (Printexc.to_string exn));
|
||||||
|
|
||||||
|
(match Erlang_ext.dispatch_count () with
|
||||||
|
| Some n when n >= 1 ->
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: erlang_ext state recorded %d dispatch(es)\n" n
|
||||||
|
| other ->
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: dispatch_count: %s\n"
|
||||||
|
(match other with Some n -> string_of_int n | None -> "None"));
|
||||||
|
|
||||||
|
Printf.printf "\nSuite: jit extension-opcode awareness\n";
|
||||||
|
let scan = Sx_vm.bytecode_uses_extension_opcodes in
|
||||||
|
let no_consts = [||] in
|
||||||
|
|
||||||
|
(* Pure core ops: scan reports false. *)
|
||||||
|
(* OP_TRUE OP_RETURN *)
|
||||||
|
if not (scan [| 3; 50 |] no_consts) then begin
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: pure core bytecode is JIT-eligible\n"
|
||||||
|
end else begin
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: pure core bytecode flagged as extension\n"
|
||||||
|
end;
|
||||||
|
|
||||||
|
(* Extension opcode anywhere → true. *)
|
||||||
|
if scan [| 220; 50 |] no_consts then begin
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: extension opcode detected at head\n"
|
||||||
|
end else begin
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: extension opcode at head missed\n"
|
||||||
|
end;
|
||||||
|
|
||||||
|
(* Mixed: core + extension → true. *)
|
||||||
|
if scan [| 3; 220; 50 |] no_consts then begin
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: extension opcode detected after core ops\n"
|
||||||
|
end else begin
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: extension opcode after core ops missed\n"
|
||||||
|
end;
|
||||||
|
|
||||||
|
(* Operand bytes ≥200 must NOT trigger. CONST u16 with index 220
|
||||||
|
into a synthetic constant pool — the operand is 220 (lo) 0 (hi),
|
||||||
|
not an opcode. The pool entry at 220 is irrelevant for the scan. *)
|
||||||
|
let big_consts = Array.make 256 Nil in
|
||||||
|
if not (scan [| 1; 220; 0; 50 |] big_consts) then begin
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: CONST operand ≥200 not a false positive\n"
|
||||||
|
end else begin
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: CONST operand ≥200 false-positives as ext op\n"
|
||||||
|
end;
|
||||||
|
|
||||||
|
(* CALL_PRIM has 3 operand bytes (u16 + u8); all ≥200 should not
|
||||||
|
trigger. *)
|
||||||
|
if not (scan [| 52; 220; 200; 200; 50 |] big_consts) then begin
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: CALL_PRIM operands ≥200 not a false positive\n"
|
||||||
|
end else begin
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: CALL_PRIM operands ≥200 false-positive\n"
|
||||||
|
end;
|
||||||
|
|
||||||
|
(* CLOSURE with upvalue descriptors: scan must skip the 2 + 2*n
|
||||||
|
dynamic operand bytes. Build a synthetic constant pool with a
|
||||||
|
Dict at index 0 declaring upvalue-count 1, descriptors that are
|
||||||
|
≥200 — the scan should skip them and not trigger.
|
||||||
|
|
||||||
|
Bytecode layout: CLOSURE 0 0 desc_is_local desc_index RETURN
|
||||||
|
op lo hi 210 220 50
|
||||||
|
With upvalue-count = 1, scan must advance past the 2-byte CLOSURE
|
||||||
|
operand AND the 2 descriptor bytes (210, 220), landing on RETURN. *)
|
||||||
|
let cl_consts = Array.make 1 Nil in
|
||||||
|
let dict = Hashtbl.create 1 in
|
||||||
|
Hashtbl.replace dict "upvalue-count" (Integer 1);
|
||||||
|
cl_consts.(0) <- Dict dict;
|
||||||
|
if not (scan [| 51; 0; 0; 210; 220; 50 |] cl_consts) then begin
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: CLOSURE upvalue descriptors ≥200 skipped\n"
|
||||||
|
end else begin
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: CLOSURE upvalue descriptors false-positive\n"
|
||||||
|
end;
|
||||||
|
|
||||||
|
(* Sanity: opcode after CLOSURE+descriptors that IS an extension
|
||||||
|
opcode triggers correctly. *)
|
||||||
|
if scan [| 51; 0; 0; 210; 220; 221; 50 |] cl_consts then begin
|
||||||
|
incr pass_count;
|
||||||
|
Printf.printf " PASS: extension opcode after CLOSURE detected\n"
|
||||||
|
end else begin
|
||||||
|
incr fail_count;
|
||||||
|
Printf.printf " FAIL: extension opcode after CLOSURE missed\n"
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
(* ====================================================================== *)
|
(* ====================================================================== *)
|
||||||
|
|||||||
@@ -18,6 +18,20 @@
|
|||||||
|
|
||||||
open Sx_types
|
open Sx_types
|
||||||
|
|
||||||
|
(* Force-link Sx_vm_extensions so its module-init runs: installs the
|
||||||
|
extension dispatch fallthrough and registers the `extension-opcode-id`
|
||||||
|
SX primitive. Without a reference here OCaml dead-code-eliminates the
|
||||||
|
module from sx_server.exe (it's only otherwise reached from run_tests),
|
||||||
|
leaving guest-language opcode extensions (Erlang Phase 9, etc.)
|
||||||
|
invisible to the runtime. The applied call is a harmless lookup. *)
|
||||||
|
let () = ignore (Sx_vm_extensions.id_of_name "")
|
||||||
|
|
||||||
|
(* Register the Erlang opcode extension (Phase 9h) so
|
||||||
|
`extension-opcode-id "erlang.OP_*"` resolves to the host ids the SX
|
||||||
|
stub dispatcher consults. Guarded: a double-register raises Failure,
|
||||||
|
which we swallow so a re-entered server process doesn't die. *)
|
||||||
|
let () = try Erlang_ext.register () with Failure _ -> ()
|
||||||
|
|
||||||
(* ====================================================================== *)
|
(* ====================================================================== *)
|
||||||
(* Font measurement via otfm — reads OpenType/TrueType font tables *)
|
(* Font measurement via otfm — reads OpenType/TrueType font tables *)
|
||||||
(* ====================================================================== *)
|
(* ====================================================================== *)
|
||||||
@@ -708,6 +722,139 @@ let setup_evaluator_bridge env =
|
|||||||
match args with
|
match args with
|
||||||
| [e; expr] -> Sx_ref.eval_expr expr e
|
| [e; expr] -> Sx_ref.eval_expr expr e
|
||||||
| _ -> raise (Eval_error "eval-in-env: (env expr)"));
|
| _ -> raise (Eval_error "eval-in-env: (env expr)"));
|
||||||
|
|
||||||
|
(* fed-sx Milestone 1 Step 8 transport. NATIVE ONLY — sockets +
|
||||||
|
threads; deliberately absent from the WASM kernel (registered
|
||||||
|
here in bin/, never in lib/sx_primitives.ml). Minimal HTTP/1.1,
|
||||||
|
Connection: close. handler : req-dict -> resp-dict where
|
||||||
|
req = {:method :path :query :headers :body},
|
||||||
|
resp = {:status :headers :body}. Never returns. *)
|
||||||
|
Sx_primitives.register "http-listen" (fun args ->
|
||||||
|
let strip_cr s =
|
||||||
|
let n = String.length s in
|
||||||
|
if n > 0 && s.[n - 1] = '\r' then String.sub s 0 (n - 1) else s
|
||||||
|
in
|
||||||
|
match args with
|
||||||
|
| [port_v; handler] ->
|
||||||
|
let port = match port_v with
|
||||||
|
| Integer n -> n
|
||||||
|
| Number f -> int_of_float f
|
||||||
|
| _ -> raise (Eval_error "http-listen: (port handler)") in
|
||||||
|
let sock = Unix.socket Unix.PF_INET Unix.SOCK_STREAM 0 in
|
||||||
|
Unix.setsockopt sock Unix.SO_REUSEADDR true;
|
||||||
|
Unix.bind sock
|
||||||
|
(Unix.ADDR_INET (Unix.inet_addr_loopback, port));
|
||||||
|
Unix.listen sock 64;
|
||||||
|
(* SX runtime is shared across threads — serialize handler calls. *)
|
||||||
|
let mtx = Mutex.create () in
|
||||||
|
let reason = function
|
||||||
|
| 200 -> "OK" | 201 -> "Created" | 204 -> "No Content"
|
||||||
|
| 301 -> "Moved Permanently" | 302 -> "Found"
|
||||||
|
| 400 -> "Bad Request" | 401 -> "Unauthorized"
|
||||||
|
| 403 -> "Forbidden" | 404 -> "Not Found"
|
||||||
|
| 405 -> "Method Not Allowed" | 500 -> "Internal Server Error"
|
||||||
|
| _ -> "OK" in
|
||||||
|
let handle fd =
|
||||||
|
(try
|
||||||
|
let ic = Unix.in_channel_of_descr fd in
|
||||||
|
let oc = Unix.out_channel_of_descr fd in
|
||||||
|
let reqline = strip_cr (input_line ic) in
|
||||||
|
(match String.split_on_char ' ' reqline with
|
||||||
|
| meth :: target :: _ ->
|
||||||
|
let path, query =
|
||||||
|
match String.index_opt target '?' with
|
||||||
|
| Some i ->
|
||||||
|
String.sub target 0 i,
|
||||||
|
String.sub target (i + 1)
|
||||||
|
(String.length target - i - 1)
|
||||||
|
| None -> target, "" in
|
||||||
|
let headers = Sx_types.make_dict () in
|
||||||
|
let clen = ref 0 in
|
||||||
|
let rec rdh () =
|
||||||
|
let h = strip_cr (input_line ic) in
|
||||||
|
if h = "" then ()
|
||||||
|
else begin
|
||||||
|
(match String.index_opt h ':' with
|
||||||
|
| Some i ->
|
||||||
|
let name =
|
||||||
|
String.lowercase_ascii
|
||||||
|
(String.trim (String.sub h 0 i)) in
|
||||||
|
let value =
|
||||||
|
String.trim
|
||||||
|
(String.sub h (i + 1)
|
||||||
|
(String.length h - i - 1)) in
|
||||||
|
Hashtbl.replace headers name (String value);
|
||||||
|
if name = "content-length" then
|
||||||
|
(try clen := int_of_string value with _ -> ())
|
||||||
|
| None -> ());
|
||||||
|
rdh ()
|
||||||
|
end in
|
||||||
|
rdh ();
|
||||||
|
let body =
|
||||||
|
if !clen > 0 then begin
|
||||||
|
let b = Bytes.create !clen in
|
||||||
|
really_input ic b 0 !clen;
|
||||||
|
Bytes.unsafe_to_string b
|
||||||
|
end else "" in
|
||||||
|
let req = Sx_types.make_dict () in
|
||||||
|
Hashtbl.replace req "method" (String meth);
|
||||||
|
Hashtbl.replace req "path" (String path);
|
||||||
|
Hashtbl.replace req "query" (String query);
|
||||||
|
Hashtbl.replace req "headers" (Dict headers);
|
||||||
|
Hashtbl.replace req "body" (String body);
|
||||||
|
Mutex.lock mtx;
|
||||||
|
let resp =
|
||||||
|
(try Sx_runtime.sx_call handler [Dict req]
|
||||||
|
with e -> Mutex.unlock mtx; raise e) in
|
||||||
|
Mutex.unlock mtx;
|
||||||
|
let getk k = match resp with
|
||||||
|
| Dict h -> Hashtbl.find_opt h k | _ -> None in
|
||||||
|
let status = match getk "status" with
|
||||||
|
| Some (Integer n) -> n
|
||||||
|
| Some (Number f) -> int_of_float f
|
||||||
|
| _ -> 200 in
|
||||||
|
let rbody = match getk "body" with
|
||||||
|
| Some (String s) -> s
|
||||||
|
| Some v -> Sx_types.value_to_string v
|
||||||
|
| None -> "" in
|
||||||
|
let rhdrs = match getk "headers" with
|
||||||
|
| Some (Dict h) ->
|
||||||
|
Hashtbl.fold (fun k v acc ->
|
||||||
|
(k, (match v with
|
||||||
|
| String s -> s
|
||||||
|
| v -> Sx_types.value_to_string v)) :: acc)
|
||||||
|
h []
|
||||||
|
| _ -> [] in
|
||||||
|
let buf = Buffer.create 256 in
|
||||||
|
Buffer.add_string buf
|
||||||
|
(Printf.sprintf "HTTP/1.1 %d %s\r\n" status
|
||||||
|
(reason status));
|
||||||
|
List.iter (fun (k, v) ->
|
||||||
|
Buffer.add_string buf
|
||||||
|
(Printf.sprintf "%s: %s\r\n" k v)) rhdrs;
|
||||||
|
if not (List.exists
|
||||||
|
(fun (k, _) ->
|
||||||
|
String.lowercase_ascii k = "content-type")
|
||||||
|
rhdrs)
|
||||||
|
then Buffer.add_string buf
|
||||||
|
"Content-Type: text/plain\r\n";
|
||||||
|
Buffer.add_string buf
|
||||||
|
(Printf.sprintf "Content-Length: %d\r\n"
|
||||||
|
(String.length rbody));
|
||||||
|
Buffer.add_string buf "Connection: close\r\n\r\n";
|
||||||
|
Buffer.add_string buf rbody;
|
||||||
|
output_string oc (Buffer.contents buf);
|
||||||
|
flush oc
|
||||||
|
| _ -> ())
|
||||||
|
with _ -> ());
|
||||||
|
(try Unix.close fd with _ -> ())
|
||||||
|
in
|
||||||
|
while true do
|
||||||
|
let fd, _ = Unix.accept sock in
|
||||||
|
ignore (Thread.create handle fd)
|
||||||
|
done;
|
||||||
|
Nil
|
||||||
|
| _ -> raise (Eval_error "http-listen: (port handler)"));
|
||||||
bind "trampoline" (fun args ->
|
bind "trampoline" (fun args ->
|
||||||
match args with
|
match args with
|
||||||
| [v] ->
|
| [v] ->
|
||||||
|
|||||||
49
hosts/ocaml/bin/test_http.sh
Executable file
49
hosts/ocaml/bin/test_http.sh
Executable file
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Phase H test — native-only http-listen primitive.
|
||||||
|
# Starts sx_server with a tiny SX echo handler, drives it with curl
|
||||||
|
# (GET / POST / 404 / custom header), asserts, then kills it.
|
||||||
|
set -u
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
SRV=_build/default/bin/sx_server.exe
|
||||||
|
PORT=${HTTP_TEST_PORT:-8911}
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
ok() { echo " PASS: $1"; PASS=$((PASS+1)); }
|
||||||
|
bad() { echo " FAIL: $1 — $2"; FAIL=$((FAIL+1)); }
|
||||||
|
|
||||||
|
if [ ! -x "$SRV" ]; then
|
||||||
|
echo "build sx_server.exe first (dune build bin/sx_server.exe)"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
H='(begin (define (h req) (if (= (get req "path") "/echo") {:status 200 :headers {"X-Echo" (get req "method")} :body (str "M=" (get req "method") " P=" (get req "path") " Q=" (get req "query") " B=" (get req "body"))} {:status 404 :body "nope"})) (http-listen '"$PORT"' h))'
|
||||||
|
ESC=${H//\"/\\\"}
|
||||||
|
|
||||||
|
{ printf '(epoch 1)\n(eval "%s")\n' "$ESC"; sleep 30; } | "$SRV" >/tmp/test_http_srv.out 2>&1 &
|
||||||
|
SVPID=$!
|
||||||
|
trap 'kill $SVPID 2>/dev/null; wait 2>/dev/null' EXIT
|
||||||
|
|
||||||
|
up=0
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
curl -s -o /dev/null "http://127.0.0.1:$PORT/echo" 2>/dev/null && { up=1; break; }
|
||||||
|
sleep 0.2
|
||||||
|
done
|
||||||
|
[ "$up" = 1 ] || { echo " FAIL: server did not start"; cat /tmp/test_http_srv.out; exit 1; }
|
||||||
|
|
||||||
|
# GET with query + custom response header.
|
||||||
|
g=$(curl -s -i "http://127.0.0.1:$PORT/echo?x=1" | tr -d '\r')
|
||||||
|
echo "$g" | grep -q '^HTTP/1.1 200 OK' && ok "GET status 200" || bad "GET status" "$g"
|
||||||
|
echo "$g" | grep -q '^X-Echo: GET' && ok "GET custom header" || bad "GET header" "$g"
|
||||||
|
echo "$g" | grep -q '^M=GET P=/echo Q=x=1 B=$' && ok "GET echo body" || bad "GET body" "$g"
|
||||||
|
|
||||||
|
# POST with body.
|
||||||
|
p=$(curl -s -X POST --data 'hello' "http://127.0.0.1:$PORT/echo")
|
||||||
|
[ "$p" = 'M=POST P=/echo Q= B=hello' ] && ok "POST body echoed" || bad "POST body" "$p"
|
||||||
|
|
||||||
|
# 404 path.
|
||||||
|
n=$(curl -s -i "http://127.0.0.1:$PORT/missing" | tr -d '\r')
|
||||||
|
echo "$n" | grep -q '^HTTP/1.1 404 Not Found' && ok "404 status" || bad "404 status" "$n"
|
||||||
|
echo "$n" | grep -q '^nope$' && ok "404 body" || bad "404 body" "$n"
|
||||||
|
|
||||||
|
echo "Results: $PASS passed, $FAIL failed"
|
||||||
|
[ "$FAIL" = 0 ]
|
||||||
@@ -2,3 +2,7 @@
|
|||||||
(name sx)
|
(name sx)
|
||||||
(wrapped false)
|
(wrapped false)
|
||||||
(libraries re re.pcre unix))
|
(libraries re re.pcre unix))
|
||||||
|
|
||||||
|
; Pull in extension modules from lib/extensions/ (test_ext.ml, etc).
|
||||||
|
; See plans/sx-vm-opcode-extension.md.
|
||||||
|
(include_subdirs unqualified)
|
||||||
|
|||||||
71
hosts/ocaml/lib/extensions/README.md
Normal file
71
hosts/ocaml/lib/extensions/README.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# SX VM extensions
|
||||||
|
|
||||||
|
Each `*.ml` file here is a VM extension — a first-class OCaml module that
|
||||||
|
registers specialized bytecode opcodes with `Sx_vm_extensions`. See
|
||||||
|
[`plans/sx-vm-opcode-extension.md`](../../../../plans/sx-vm-opcode-extension.md)
|
||||||
|
for the design.
|
||||||
|
|
||||||
|
## Pattern
|
||||||
|
|
||||||
|
```ocaml
|
||||||
|
(* lib/extensions/myport.ml *)
|
||||||
|
open Sx_types
|
||||||
|
|
||||||
|
type Sx_vm_extension.extension_state += MyportState of { ... }
|
||||||
|
|
||||||
|
module M : Sx_vm_extension.EXTENSION = struct
|
||||||
|
let name = "myport"
|
||||||
|
let init () = MyportState { ... }
|
||||||
|
let opcodes _st = [
|
||||||
|
(id, "myport.OP_NAME", handler);
|
||||||
|
...
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
let register () = Sx_vm_extensions.register (module M)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then call `Myport.register ()` once at startup from any binary that
|
||||||
|
should have the extension loaded.
|
||||||
|
|
||||||
|
## Opcode-ID allocation
|
||||||
|
|
||||||
|
Range 200-247 (per `Sx_vm_extensions.extension_min` /
|
||||||
|
`extension_max`). Conventions:
|
||||||
|
|
||||||
|
| Range | Use |
|
||||||
|
|---------|-------------------------------------------------------------------------|
|
||||||
|
| 200-209 | reserved for `lib/guest/vm/` shared opcodes (chiselled out on 2nd use) |
|
||||||
|
| 210-219 | inline test extensions defined in `bin/run_tests.ml` |
|
||||||
|
| 220-229 | this directory's `test_ext` (the canonical template) |
|
||||||
|
| 230-247 | first-come-first-served by language ports (Erlang first) |
|
||||||
|
|
||||||
|
When a port claims a contiguous block, document it in the table above.
|
||||||
|
The registry rejects collisions at startup with a loud error — there is
|
||||||
|
no silent shadowing.
|
||||||
|
|
||||||
|
## Naming
|
||||||
|
|
||||||
|
Always prefix opcode names with the extension name plus a dot:
|
||||||
|
`myport.OP_<NAME>`. The prefix is a hard convention so that multiple
|
||||||
|
extensions can share the global opcode-name namespace cleanly.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
`extension_state` is an extensible variant. Add your case (e.g.
|
||||||
|
`MyportState of { ... }`) at the top of your file, return it from
|
||||||
|
`init`, and pattern-match it inside your handlers. Other extensions
|
||||||
|
cannot see your state — the variant case is private to your module.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`test_ext.ml` is the canonical worked example. `bin/run_tests.ml`
|
||||||
|
calls `Test_ext.register ()`, then drives bytecode that exercises the
|
||||||
|
opcodes end-to-end (push, double, dispatch, disassemble, invocation
|
||||||
|
counter). Mirror this shape when adding a real port's extension.
|
||||||
|
|
||||||
|
## Build wiring
|
||||||
|
|
||||||
|
`lib/dune` has `(include_subdirs unqualified)`, so any `.ml` you drop
|
||||||
|
in here is automatically part of the `sx` library. Module name follows
|
||||||
|
the filename verbatim (`test_ext.ml` → `Test_ext`).
|
||||||
278
hosts/ocaml/lib/extensions/erlang_ext.ml
Normal file
278
hosts/ocaml/lib/extensions/erlang_ext.ml
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
(** {1 [erlang_ext] — Erlang-on-SX VM opcode extension (Phase 9h)}
|
||||||
|
|
||||||
|
Registers the Erlang opcode namespace in [Sx_vm_extensions] so that
|
||||||
|
[extension-opcode-id "erlang.OP_*"] resolves to a stable id. The SX
|
||||||
|
stub dispatcher in [lib/erlang/vm/dispatcher.sx] consults these ids
|
||||||
|
(Phase 9i) and falls back to its own local ids when the host
|
||||||
|
extension is absent.
|
||||||
|
|
||||||
|
Opcode ids occupy 222-239 in the extension partition (200-247).
|
||||||
|
222+ is chosen to clear the test extensions' reserved ids
|
||||||
|
(test_reg 210/211, test_ext 220/221) so all three coexist in
|
||||||
|
run_tests; production sx_server only registers this one. Names
|
||||||
|
mirror the SX stub dispatcher exactly:
|
||||||
|
|
||||||
|
- 222 erlang.OP_PATTERN_TUPLE - 231 erlang.OP_BIF_HD
|
||||||
|
- 223 erlang.OP_PATTERN_LIST - 232 erlang.OP_BIF_TL
|
||||||
|
- 224 erlang.OP_PATTERN_BINARY - 233 erlang.OP_BIF_ELEMENT
|
||||||
|
- 225 erlang.OP_PERFORM - 234 erlang.OP_BIF_TUPLE_SIZE
|
||||||
|
- 226 erlang.OP_HANDLE - 235 erlang.OP_BIF_LISTS_REVERSE
|
||||||
|
- 227 erlang.OP_RECEIVE_SCAN - 236 erlang.OP_BIF_IS_INTEGER
|
||||||
|
- 228 erlang.OP_SPAWN - 237 erlang.OP_BIF_IS_ATOM
|
||||||
|
- 229 erlang.OP_SEND - 238 erlang.OP_BIF_IS_LIST
|
||||||
|
- 230 erlang.OP_BIF_LENGTH - 239 erlang.OP_BIF_IS_TUPLE
|
||||||
|
|
||||||
|
{2 Handler status}
|
||||||
|
|
||||||
|
The bytecode compiler does not yet emit these opcodes — Erlang
|
||||||
|
programs run through the general CEK path and the working
|
||||||
|
specialization path is the SX stub dispatcher. So every handler
|
||||||
|
here raises a descriptive [Eval_error] rather than silently
|
||||||
|
corrupting the VM stack. This keeps the extension honest: the
|
||||||
|
namespace is registered and disassembles by name, [extension-opcode-id]
|
||||||
|
works, but actually dispatching an opcode (which only happens once a
|
||||||
|
future phase teaches the compiler to emit them) fails loudly with a
|
||||||
|
pointer to the phase that will wire it. Real stack-machine handlers
|
||||||
|
land alongside compiler emission in a later phase. *)
|
||||||
|
|
||||||
|
open Sx_types
|
||||||
|
|
||||||
|
(** Per-instance state: invocation counter, purely to exercise the
|
||||||
|
[extension_state] machinery (mirrors [test_ext]). *)
|
||||||
|
type Sx_vm_extension.extension_state += ErlangExtState of {
|
||||||
|
mutable dispatched : int;
|
||||||
|
}
|
||||||
|
|
||||||
|
let not_wired name =
|
||||||
|
raise (Eval_error
|
||||||
|
(Printf.sprintf
|
||||||
|
"%s: bytecode emission not yet wired (Phase 9j) — \
|
||||||
|
Erlang runs via CEK; specialization path is the SX stub \
|
||||||
|
dispatcher in lib/erlang/vm/dispatcher.sx"
|
||||||
|
name))
|
||||||
|
|
||||||
|
module M : Sx_vm_extension.EXTENSION = struct
|
||||||
|
let name = "erlang"
|
||||||
|
let init () = ErlangExtState { dispatched = 0 }
|
||||||
|
|
||||||
|
let opcodes st =
|
||||||
|
let bump () = match st with
|
||||||
|
| ErlangExtState s -> s.dispatched <- s.dispatched + 1
|
||||||
|
| _ -> ()
|
||||||
|
in
|
||||||
|
let op id nm =
|
||||||
|
(id, nm, (fun (_vm : Sx_vm.vm) (_frame : Sx_vm.frame) ->
|
||||||
|
bump (); not_wired nm))
|
||||||
|
in
|
||||||
|
(* Phase 10b vertical slice: one REAL register-machine handler.
|
||||||
|
erlang.OP_BIF_LENGTH (230) — pops an Erlang list off the VM
|
||||||
|
stack and pushes its length. Proves the full path works:
|
||||||
|
extension-opcode-id -> bytecode -> Sx_vm dispatch fallthrough
|
||||||
|
-> this handler -> correct stack result. The remaining 17
|
||||||
|
opcodes still raise not_wired until their handlers + compiler
|
||||||
|
emission land. Erlang lists are tagged dicts:
|
||||||
|
nil = {"tag" -> String "nil"}
|
||||||
|
cons = {"tag" -> String "cons"; "head" -> v; "tail" -> v} *)
|
||||||
|
let er_tag d =
|
||||||
|
match Hashtbl.find_opt d "tag" with
|
||||||
|
| Some (String s) -> s | _ -> ""
|
||||||
|
in
|
||||||
|
let op_bif_length =
|
||||||
|
(230, "erlang.OP_BIF_LENGTH",
|
||||||
|
(fun (vm : Sx_vm.vm) (_frame : Sx_vm.frame) ->
|
||||||
|
bump ();
|
||||||
|
let v = Sx_vm.pop vm in
|
||||||
|
let rec walk acc node =
|
||||||
|
match node with
|
||||||
|
| Dict d ->
|
||||||
|
(match er_tag d with
|
||||||
|
| "nil" -> acc
|
||||||
|
| "cons" ->
|
||||||
|
(match Hashtbl.find_opt d "tail" with
|
||||||
|
| Some t -> walk (acc + 1) t
|
||||||
|
| None -> raise (Eval_error
|
||||||
|
"erlang.OP_BIF_LENGTH: cons cell without :tail"))
|
||||||
|
| _ -> raise (Eval_error
|
||||||
|
"erlang.OP_BIF_LENGTH: not a proper list"))
|
||||||
|
| _ -> raise (Eval_error
|
||||||
|
"erlang.OP_BIF_LENGTH: not a proper list")
|
||||||
|
in
|
||||||
|
Sx_vm.push vm (Integer (walk 0 v))))
|
||||||
|
in
|
||||||
|
(* Phase 10b — simple hot-BIF handlers. Erlang bool is the atom
|
||||||
|
{"tag"->"atom"; "name"->"true"|"false"}; mk_atom builds it. *)
|
||||||
|
let mk_atom nm =
|
||||||
|
let h = Hashtbl.create 2 in
|
||||||
|
Hashtbl.replace h "tag" (String "atom");
|
||||||
|
Hashtbl.replace h "name" (String nm);
|
||||||
|
Dict h
|
||||||
|
in
|
||||||
|
let er_bool b = mk_atom (if b then "true" else "false") in
|
||||||
|
let is_tag v t = match v with
|
||||||
|
| Dict d -> er_tag d = t
|
||||||
|
| _ -> false
|
||||||
|
in
|
||||||
|
let op_bif_hd =
|
||||||
|
(231, "erlang.OP_BIF_HD",
|
||||||
|
(fun (vm : Sx_vm.vm) _f ->
|
||||||
|
bump ();
|
||||||
|
match Sx_vm.pop vm with
|
||||||
|
| Dict d when er_tag d = "cons" ->
|
||||||
|
(match Hashtbl.find_opt d "head" with
|
||||||
|
| Some h -> Sx_vm.push vm h
|
||||||
|
| None -> raise (Eval_error "erlang.OP_BIF_HD: cons without :head"))
|
||||||
|
| _ -> raise (Eval_error "erlang.OP_BIF_HD: not a cons")))
|
||||||
|
in
|
||||||
|
let op_bif_tl =
|
||||||
|
(232, "erlang.OP_BIF_TL",
|
||||||
|
(fun (vm : Sx_vm.vm) _f ->
|
||||||
|
bump ();
|
||||||
|
match Sx_vm.pop vm with
|
||||||
|
| Dict d when er_tag d = "cons" ->
|
||||||
|
(match Hashtbl.find_opt d "tail" with
|
||||||
|
| Some t -> Sx_vm.push vm t
|
||||||
|
| None -> raise (Eval_error "erlang.OP_BIF_TL: cons without :tail"))
|
||||||
|
| _ -> raise (Eval_error "erlang.OP_BIF_TL: not a cons")))
|
||||||
|
in
|
||||||
|
let op_bif_tuple_size =
|
||||||
|
(234, "erlang.OP_BIF_TUPLE_SIZE",
|
||||||
|
(fun (vm : Sx_vm.vm) _f ->
|
||||||
|
bump ();
|
||||||
|
match Sx_vm.pop vm with
|
||||||
|
| Dict d when er_tag d = "tuple" ->
|
||||||
|
let n = match Hashtbl.find_opt d "elements" with
|
||||||
|
| Some (List es) -> List.length es
|
||||||
|
| Some (ListRef r) -> List.length !r
|
||||||
|
| _ -> raise (Eval_error
|
||||||
|
"erlang.OP_BIF_TUPLE_SIZE: tuple without :elements")
|
||||||
|
in
|
||||||
|
Sx_vm.push vm (Integer n)
|
||||||
|
| _ -> raise (Eval_error "erlang.OP_BIF_TUPLE_SIZE: not a tuple")))
|
||||||
|
in
|
||||||
|
let op_bif_is_integer =
|
||||||
|
(236, "erlang.OP_BIF_IS_INTEGER",
|
||||||
|
(fun (vm : Sx_vm.vm) _f ->
|
||||||
|
bump ();
|
||||||
|
let v = Sx_vm.pop vm in
|
||||||
|
Sx_vm.push vm (er_bool (match v with Integer _ -> true | _ -> false))))
|
||||||
|
in
|
||||||
|
let op_bif_is_atom =
|
||||||
|
(237, "erlang.OP_BIF_IS_ATOM",
|
||||||
|
(fun (vm : Sx_vm.vm) _f ->
|
||||||
|
bump ();
|
||||||
|
let v = Sx_vm.pop vm in
|
||||||
|
Sx_vm.push vm (er_bool (is_tag v "atom"))))
|
||||||
|
in
|
||||||
|
let op_bif_is_list =
|
||||||
|
(238, "erlang.OP_BIF_IS_LIST",
|
||||||
|
(fun (vm : Sx_vm.vm) _f ->
|
||||||
|
bump ();
|
||||||
|
let v = Sx_vm.pop vm in
|
||||||
|
Sx_vm.push vm (er_bool (is_tag v "cons" || is_tag v "nil"))))
|
||||||
|
in
|
||||||
|
let op_bif_is_tuple =
|
||||||
|
(239, "erlang.OP_BIF_IS_TUPLE",
|
||||||
|
(fun (vm : Sx_vm.vm) _f ->
|
||||||
|
bump ();
|
||||||
|
let v = Sx_vm.pop vm in
|
||||||
|
Sx_vm.push vm (er_bool (is_tag v "tuple"))))
|
||||||
|
in
|
||||||
|
(* element/2 and lists:reverse/1 — pure stack transforms (no
|
||||||
|
bytecode operands). Calling convention: args pushed left→right,
|
||||||
|
so element/2 stack is [.. Index Tuple] (Tuple on top). Erlang
|
||||||
|
element/2 is 1-indexed. *)
|
||||||
|
let op_bif_element =
|
||||||
|
(233, "erlang.OP_BIF_ELEMENT",
|
||||||
|
(fun (vm : Sx_vm.vm) _f ->
|
||||||
|
bump ();
|
||||||
|
let tup = Sx_vm.pop vm in
|
||||||
|
let idx = Sx_vm.pop vm in
|
||||||
|
match tup, idx with
|
||||||
|
| Dict d, Integer i when er_tag d = "tuple" ->
|
||||||
|
let es = match Hashtbl.find_opt d "elements" with
|
||||||
|
| Some (List es) -> es
|
||||||
|
| Some (ListRef r) -> !r
|
||||||
|
| _ -> raise (Eval_error
|
||||||
|
"erlang.OP_BIF_ELEMENT: tuple without :elements")
|
||||||
|
in
|
||||||
|
let n = List.length es in
|
||||||
|
if i < 1 || i > n then
|
||||||
|
raise (Eval_error
|
||||||
|
(Printf.sprintf
|
||||||
|
"erlang.OP_BIF_ELEMENT: index %d out of range 1..%d" i n))
|
||||||
|
else
|
||||||
|
Sx_vm.push vm (List.nth es (i - 1))
|
||||||
|
| _, Integer _ ->
|
||||||
|
raise (Eval_error "erlang.OP_BIF_ELEMENT: 2nd arg not a tuple")
|
||||||
|
| _ ->
|
||||||
|
raise (Eval_error "erlang.OP_BIF_ELEMENT: 1st arg not an integer")))
|
||||||
|
in
|
||||||
|
let op_bif_lists_reverse =
|
||||||
|
(235, "erlang.OP_BIF_LISTS_REVERSE",
|
||||||
|
(fun (vm : Sx_vm.vm) _f ->
|
||||||
|
bump ();
|
||||||
|
let v = Sx_vm.pop vm in
|
||||||
|
let mk_nil () =
|
||||||
|
let h = Hashtbl.create 1 in
|
||||||
|
Hashtbl.replace h "tag" (String "nil"); Dict h in
|
||||||
|
let mk_cons hd tl =
|
||||||
|
let h = Hashtbl.create 3 in
|
||||||
|
Hashtbl.replace h "tag" (String "cons");
|
||||||
|
Hashtbl.replace h "head" hd;
|
||||||
|
Hashtbl.replace h "tail" tl;
|
||||||
|
Dict h in
|
||||||
|
let rec rev acc node =
|
||||||
|
match node with
|
||||||
|
| Dict d ->
|
||||||
|
(match er_tag d with
|
||||||
|
| "nil" -> acc
|
||||||
|
| "cons" ->
|
||||||
|
let hd = match Hashtbl.find_opt d "head" with
|
||||||
|
| Some x -> x
|
||||||
|
| None -> raise (Eval_error
|
||||||
|
"erlang.OP_BIF_LISTS_REVERSE: cons without :head") in
|
||||||
|
let tl = match Hashtbl.find_opt d "tail" with
|
||||||
|
| Some x -> x
|
||||||
|
| None -> raise (Eval_error
|
||||||
|
"erlang.OP_BIF_LISTS_REVERSE: cons without :tail") in
|
||||||
|
rev (mk_cons hd acc) tl
|
||||||
|
| _ -> raise (Eval_error
|
||||||
|
"erlang.OP_BIF_LISTS_REVERSE: not a proper list"))
|
||||||
|
| _ -> raise (Eval_error
|
||||||
|
"erlang.OP_BIF_LISTS_REVERSE: not a proper list")
|
||||||
|
in
|
||||||
|
Sx_vm.push vm (rev (mk_nil ()) v)))
|
||||||
|
in
|
||||||
|
[
|
||||||
|
op 222 "erlang.OP_PATTERN_TUPLE";
|
||||||
|
op 223 "erlang.OP_PATTERN_LIST";
|
||||||
|
op 224 "erlang.OP_PATTERN_BINARY";
|
||||||
|
op 225 "erlang.OP_PERFORM";
|
||||||
|
op 226 "erlang.OP_HANDLE";
|
||||||
|
op 227 "erlang.OP_RECEIVE_SCAN";
|
||||||
|
op 228 "erlang.OP_SPAWN";
|
||||||
|
op 229 "erlang.OP_SEND";
|
||||||
|
op_bif_length;
|
||||||
|
op_bif_hd;
|
||||||
|
op_bif_tl;
|
||||||
|
op_bif_element;
|
||||||
|
op_bif_tuple_size;
|
||||||
|
op_bif_lists_reverse;
|
||||||
|
op_bif_is_integer;
|
||||||
|
op_bif_is_atom;
|
||||||
|
op_bif_is_list;
|
||||||
|
op_bif_is_tuple;
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
(** Register [erlang] in [Sx_vm_extensions]. Idempotent only by failing
|
||||||
|
loudly — calling twice raises [Failure]. sx_server calls this once
|
||||||
|
at startup. *)
|
||||||
|
let register () = Sx_vm_extensions.register (module M : Sx_vm_extension.EXTENSION)
|
||||||
|
|
||||||
|
(** Read the dispatch counter from the live registry state. [None] if
|
||||||
|
[register] hasn't run. *)
|
||||||
|
let dispatch_count () =
|
||||||
|
match Sx_vm_extensions.state_of_extension "erlang" with
|
||||||
|
| Some (ErlangExtState s) -> Some s.dispatched
|
||||||
|
| _ -> None
|
||||||
67
hosts/ocaml/lib/extensions/test_ext.ml
Normal file
67
hosts/ocaml/lib/extensions/test_ext.ml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
(** {1 [test_ext] — canonical example VM extension}
|
||||||
|
|
||||||
|
A minimal extension demonstrating the registration pattern from
|
||||||
|
[plans/sx-vm-opcode-extension.md]. The opcode IDs (220, 221) sit at
|
||||||
|
the top of the extension range, well clear of anything a real
|
||||||
|
language port would claim.
|
||||||
|
|
||||||
|
Two operand-less opcodes:
|
||||||
|
|
||||||
|
- [test_ext.OP_TEST_PUSH_42] (220) — pushes the integer 42.
|
||||||
|
- [test_ext.OP_TEST_DOUBLE_TOS] (221) — pops the integer on TOS,
|
||||||
|
pushes 2× it.
|
||||||
|
|
||||||
|
These are the smallest stack manipulations that prove the extension
|
||||||
|
mechanism wires through end-to-end (registry → dispatch → human-
|
||||||
|
readable disassembly). Real ports (Erlang Phase 9, future Haskell
|
||||||
|
perf phases) replace this template with their own opcode set.
|
||||||
|
|
||||||
|
Loading: [Test_ext.register ()] adds the extension to
|
||||||
|
[Sx_vm_extensions]. Run-time binaries that want the test opcodes
|
||||||
|
available call this once at startup. Unit tests in
|
||||||
|
[bin/run_tests.ml] do exactly that. *)
|
||||||
|
|
||||||
|
open Sx_types
|
||||||
|
|
||||||
|
(** Per-instance state for [test_ext]. Counts how many times the
|
||||||
|
handlers ran — purely so the extension has *some* state, exercising
|
||||||
|
the [extension_state] machinery. *)
|
||||||
|
type Sx_vm_extension.extension_state += TestExtState of {
|
||||||
|
mutable invocations : int;
|
||||||
|
}
|
||||||
|
|
||||||
|
module M : Sx_vm_extension.EXTENSION = struct
|
||||||
|
let name = "test_ext"
|
||||||
|
let init () = TestExtState { invocations = 0 }
|
||||||
|
|
||||||
|
let opcodes st =
|
||||||
|
let bump () = match st with
|
||||||
|
| TestExtState s -> s.invocations <- s.invocations + 1
|
||||||
|
| _ -> ()
|
||||||
|
in
|
||||||
|
[
|
||||||
|
(220, "test_ext.OP_TEST_PUSH_42",
|
||||||
|
(fun vm _frame -> bump (); Sx_vm.push vm (Integer 42)));
|
||||||
|
|
||||||
|
(221, "test_ext.OP_TEST_DOUBLE_TOS",
|
||||||
|
(fun vm _frame ->
|
||||||
|
bump ();
|
||||||
|
let v = Sx_vm.pop vm in
|
||||||
|
match v with
|
||||||
|
| Integer n -> Sx_vm.push vm (Integer (n * 2))
|
||||||
|
| _ -> raise (Eval_error
|
||||||
|
"test_ext.OP_TEST_DOUBLE_TOS: TOS is not an integer")));
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
(** Register [test_ext] in [Sx_vm_extensions]. Idempotent only by
|
||||||
|
failing loudly — calling twice raises [Failure]. Binaries call this
|
||||||
|
once at startup; tests may [_reset_for_tests] then re-register. *)
|
||||||
|
let register () = Sx_vm_extensions.register (module M : Sx_vm_extension.EXTENSION)
|
||||||
|
|
||||||
|
(** Read the invocation counter from the live registry state. Returns
|
||||||
|
[None] if [register] hasn't been called yet. *)
|
||||||
|
let invocation_count () =
|
||||||
|
match Sx_vm_extensions.state_of_extension "test_ext" with
|
||||||
|
| Some (TestExtState s) -> Some s.invocations
|
||||||
|
| _ -> None
|
||||||
142
hosts/ocaml/lib/sx_cbor.ml
Normal file
142
hosts/ocaml/lib/sx_cbor.ml
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
(** dag-cbor encode / decode — pure OCaml, WASM-safe.
|
||||||
|
|
||||||
|
RFC 8949 deterministic subset as constrained by IPLD dag-cbor
|
||||||
|
(RFC 8742): unsigned/negative ints, text strings, arrays, maps
|
||||||
|
with keys sorted by **length-then-bytewise**, bool, null, and
|
||||||
|
tag 42 (CID link, decode-side passthrough). Floats are not
|
||||||
|
supported (no fed-sx shape needs them yet) — encoding a [Number]
|
||||||
|
or decoding a float head raises. Reference: RFC 8949 §3, §4.2. *)
|
||||||
|
|
||||||
|
open Sx_types
|
||||||
|
|
||||||
|
exception Cbor_error of string
|
||||||
|
|
||||||
|
(* ---- Encoder ---- *)
|
||||||
|
|
||||||
|
let write_head buf major v =
|
||||||
|
let m = major lsl 5 in
|
||||||
|
if v < 24 then
|
||||||
|
Buffer.add_char buf (Char.chr (m lor v))
|
||||||
|
else if v < 0x100 then begin
|
||||||
|
Buffer.add_char buf (Char.chr (m lor 24));
|
||||||
|
Buffer.add_char buf (Char.chr v)
|
||||||
|
end else if v < 0x10000 then begin
|
||||||
|
Buffer.add_char buf (Char.chr (m lor 25));
|
||||||
|
Buffer.add_char buf (Char.chr ((v lsr 8) land 0xFF));
|
||||||
|
Buffer.add_char buf (Char.chr (v land 0xFF))
|
||||||
|
end else if v < 0x100000000 then begin
|
||||||
|
Buffer.add_char buf (Char.chr (m lor 26));
|
||||||
|
for i = 3 downto 0 do
|
||||||
|
Buffer.add_char buf (Char.chr ((v lsr (8 * i)) land 0xFF))
|
||||||
|
done
|
||||||
|
end else begin
|
||||||
|
Buffer.add_char buf (Char.chr (m lor 27));
|
||||||
|
for i = 7 downto 0 do
|
||||||
|
Buffer.add_char buf (Char.chr ((v lsr (8 * i)) land 0xFF))
|
||||||
|
done
|
||||||
|
end
|
||||||
|
|
||||||
|
(* dag-cbor map key order: shorter key first, then bytewise. *)
|
||||||
|
let key_order a b =
|
||||||
|
let la = String.length a and lb = String.length b in
|
||||||
|
if la <> lb then compare la lb else compare a b
|
||||||
|
|
||||||
|
let rec encode_into buf (v : value) : unit =
|
||||||
|
match v with
|
||||||
|
| Integer n ->
|
||||||
|
if n >= 0 then write_head buf 0 n
|
||||||
|
else write_head buf 1 (-1 - n)
|
||||||
|
| String s ->
|
||||||
|
write_head buf 3 (String.length s);
|
||||||
|
Buffer.add_string buf s
|
||||||
|
| Symbol s | Keyword s ->
|
||||||
|
write_head buf 3 (String.length s);
|
||||||
|
Buffer.add_string buf s
|
||||||
|
| Bool false -> Buffer.add_char buf '\xf4'
|
||||||
|
| Bool true -> Buffer.add_char buf '\xf5'
|
||||||
|
| Nil -> Buffer.add_char buf '\xf6'
|
||||||
|
| List items ->
|
||||||
|
write_head buf 4 (List.length items);
|
||||||
|
List.iter (encode_into buf) items
|
||||||
|
| Dict d ->
|
||||||
|
let keys = Hashtbl.fold (fun k _ acc -> k :: acc) d [] in
|
||||||
|
let keys = List.sort_uniq key_order keys in
|
||||||
|
write_head buf 5 (List.length keys);
|
||||||
|
List.iter (fun k ->
|
||||||
|
write_head buf 3 (String.length k);
|
||||||
|
Buffer.add_string buf k;
|
||||||
|
encode_into buf (Hashtbl.find d k)) keys
|
||||||
|
| Number _ ->
|
||||||
|
raise (Cbor_error "cbor-encode: floats unsupported (dag-cbor subset)")
|
||||||
|
| _ ->
|
||||||
|
raise (Cbor_error
|
||||||
|
("cbor-encode: unencodable value " ^ type_of v))
|
||||||
|
|
||||||
|
let encode (v : value) : string =
|
||||||
|
let buf = Buffer.create 64 in
|
||||||
|
encode_into buf v;
|
||||||
|
Buffer.contents buf
|
||||||
|
|
||||||
|
(* ---- Decoder ---- *)
|
||||||
|
|
||||||
|
let decode (s : string) : value =
|
||||||
|
let pos = ref 0 in
|
||||||
|
let len = String.length s in
|
||||||
|
let byte () =
|
||||||
|
if !pos >= len then raise (Cbor_error "cbor-decode: truncated");
|
||||||
|
let c = Char.code s.[!pos] in incr pos; c
|
||||||
|
in
|
||||||
|
let read_uint ai =
|
||||||
|
if ai < 24 then ai
|
||||||
|
else if ai = 24 then byte ()
|
||||||
|
else if ai = 25 then let a = byte () in let b = byte () in (a lsl 8) lor b
|
||||||
|
else if ai = 26 then begin
|
||||||
|
let v = ref 0 in
|
||||||
|
for _ = 0 to 3 do v := (!v lsl 8) lor byte () done; !v
|
||||||
|
end else if ai = 27 then begin
|
||||||
|
let v = ref 0 in
|
||||||
|
for _ = 0 to 7 do v := (!v lsl 8) lor byte () done; !v
|
||||||
|
end else raise (Cbor_error "cbor-decode: bad additional info")
|
||||||
|
in
|
||||||
|
let read_bytes n =
|
||||||
|
if !pos + n > len then raise (Cbor_error "cbor-decode: truncated");
|
||||||
|
let r = String.sub s !pos n in pos := !pos + n; r
|
||||||
|
in
|
||||||
|
let rec item () =
|
||||||
|
let b = byte () in
|
||||||
|
let major = b lsr 5 and ai = b land 0x1f in
|
||||||
|
match major with
|
||||||
|
| 0 -> Integer (read_uint ai)
|
||||||
|
| 1 -> Integer (-1 - read_uint ai)
|
||||||
|
| 2 -> String (read_bytes (read_uint ai))
|
||||||
|
| 3 -> String (read_bytes (read_uint ai))
|
||||||
|
| 4 ->
|
||||||
|
let n = read_uint ai in
|
||||||
|
List (List.init n (fun _ -> item ()))
|
||||||
|
| 5 ->
|
||||||
|
let n = read_uint ai in
|
||||||
|
let d = make_dict () in
|
||||||
|
for _ = 1 to n do
|
||||||
|
let k = match item () with
|
||||||
|
| String k -> k
|
||||||
|
| _ -> raise (Cbor_error "cbor-decode: non-string map key")
|
||||||
|
in
|
||||||
|
Hashtbl.replace d k (item ())
|
||||||
|
done;
|
||||||
|
Dict d
|
||||||
|
| 6 ->
|
||||||
|
(* Tag: tag-42 CID link → pass the inner item through. *)
|
||||||
|
ignore (read_uint ai); item ()
|
||||||
|
| 7 ->
|
||||||
|
(match ai with
|
||||||
|
| 20 -> Bool false
|
||||||
|
| 21 -> Bool true
|
||||||
|
| 22 -> Nil
|
||||||
|
| 23 -> Nil
|
||||||
|
| _ ->
|
||||||
|
raise (Cbor_error
|
||||||
|
"cbor-decode: floats/simple unsupported (dag-cbor subset)"))
|
||||||
|
| _ -> raise (Cbor_error "cbor-decode: bad major type")
|
||||||
|
in
|
||||||
|
let v = item () in
|
||||||
|
v
|
||||||
66
hosts/ocaml/lib/sx_cid.ml
Normal file
66
hosts/ocaml/lib/sx_cid.ml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
(** CIDv1 computation — pure OCaml, WASM-safe.
|
||||||
|
|
||||||
|
Multihash + CIDv1 + multibase base32-lower (RFC 4648, no pad,
|
||||||
|
multibase prefix 'b'). Codecs: dag-cbor 0x71, raw 0x55. Hash
|
||||||
|
codes: sha2-256 0x12, sha3-256 0x16. Reference: the multiformats
|
||||||
|
specs (unsigned-varint, multihash, cid, multibase). No deps. *)
|
||||||
|
|
||||||
|
open Sx_types
|
||||||
|
|
||||||
|
(* Unsigned LEB128 (multiformats unsigned-varint). *)
|
||||||
|
let varint (n : int) : string =
|
||||||
|
let buf = Buffer.create 4 in
|
||||||
|
let n = ref n in
|
||||||
|
let cont = ref true in
|
||||||
|
while !cont do
|
||||||
|
let b = !n land 0x7f in
|
||||||
|
n := !n lsr 7;
|
||||||
|
if !n = 0 then (Buffer.add_char buf (Char.chr b); cont := false)
|
||||||
|
else Buffer.add_char buf (Char.chr (b lor 0x80))
|
||||||
|
done;
|
||||||
|
Buffer.contents buf
|
||||||
|
|
||||||
|
(* RFC 4648 base32 lowercase, no padding. *)
|
||||||
|
let b32_alpha = "abcdefghijklmnopqrstuvwxyz234567"
|
||||||
|
|
||||||
|
let base32_lower (s : string) : string =
|
||||||
|
let buf = Buffer.create ((String.length s * 8 + 4) / 5) in
|
||||||
|
let acc = ref 0 and bits = ref 0 in
|
||||||
|
String.iter (fun c ->
|
||||||
|
acc := (!acc lsl 8) lor (Char.code c);
|
||||||
|
bits := !bits + 8;
|
||||||
|
while !bits >= 5 do
|
||||||
|
bits := !bits - 5;
|
||||||
|
Buffer.add_char buf b32_alpha.[(!acc lsr !bits) land 0x1f]
|
||||||
|
done) s;
|
||||||
|
if !bits > 0 then
|
||||||
|
Buffer.add_char buf b32_alpha.[(!acc lsl (5 - !bits)) land 0x1f];
|
||||||
|
Buffer.contents buf
|
||||||
|
|
||||||
|
(* "abef" -> the 2 raw bytes. *)
|
||||||
|
let unhex (h : string) : string =
|
||||||
|
let n = String.length h / 2 in
|
||||||
|
let b = Bytes.create n in
|
||||||
|
for i = 0 to n - 1 do
|
||||||
|
Bytes.set b i
|
||||||
|
(Char.chr (int_of_string ("0x" ^ String.sub h (2 * i) 2)))
|
||||||
|
done;
|
||||||
|
Bytes.unsafe_to_string b
|
||||||
|
|
||||||
|
(* multihash = varint(code) || varint(len) || digest *)
|
||||||
|
let multihash (code : int) (digest : string) : string =
|
||||||
|
varint code ^ varint (String.length digest) ^ digest
|
||||||
|
|
||||||
|
(* CIDv1 = 0x01 || varint(codec) || multihash ; multibase 'b' base32. *)
|
||||||
|
let cidv1 (codec : int) (mh : string) : string =
|
||||||
|
"b" ^ base32_lower ("\x01" ^ varint codec ^ mh)
|
||||||
|
|
||||||
|
let codec_dag_cbor = 0x71
|
||||||
|
let mh_sha2_256 = 0x12
|
||||||
|
|
||||||
|
(* Canonicalize an SX value: dag-cbor encode -> sha2-256 ->
|
||||||
|
multihash -> CIDv1 (dag-cbor codec). *)
|
||||||
|
let cid_from_sx (v : value) : string =
|
||||||
|
let cbor = Sx_cbor.encode v in
|
||||||
|
let digest = unhex (Sx_sha2.sha256_hex cbor) in
|
||||||
|
cidv1 codec_dag_cbor (multihash mh_sha2_256 digest)
|
||||||
289
hosts/ocaml/lib/sx_ed25519.ml
Normal file
289
hosts/ocaml/lib/sx_ed25519.ml
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
(** Ed25519 signature verification — pure OCaml, WASM-safe.
|
||||||
|
|
||||||
|
RFC 8032 §5.1.7 cofactorless verify over edwards25519. Includes a
|
||||||
|
minimal arbitrary-precision unsigned bignum (no Zarith / no deps)
|
||||||
|
and twisted-Edwards extended-coordinate point arithmetic. Verify
|
||||||
|
is total: malformed inputs return [false], never raise. SHA-512
|
||||||
|
is reused from {!Sx_sha2}. Reference: RFC 8032, RFC 7748. *)
|
||||||
|
|
||||||
|
(* ---- Minimal bignum: int array, little-endian, base 2^26. ---- *)
|
||||||
|
|
||||||
|
let bits = 26
|
||||||
|
let base = 1 lsl bits
|
||||||
|
let mask = base - 1
|
||||||
|
|
||||||
|
type bn = int array (* normalized: no high zero limbs, length >= 1 *)
|
||||||
|
|
||||||
|
let norm (a : bn) : bn =
|
||||||
|
let n = ref (Array.length a) in
|
||||||
|
while !n > 1 && a.(!n - 1) = 0 do decr n done;
|
||||||
|
if !n = Array.length a then a else Array.sub a 0 !n
|
||||||
|
|
||||||
|
let bzero : bn = [| 0 |]
|
||||||
|
let of_int n : bn =
|
||||||
|
if n = 0 then bzero
|
||||||
|
else begin
|
||||||
|
let r = ref [] and n = ref n in
|
||||||
|
while !n > 0 do r := (!n land mask) :: !r; n := !n lsr bits done;
|
||||||
|
norm (Array.of_list (List.rev !r))
|
||||||
|
end
|
||||||
|
|
||||||
|
let is_zero (a : bn) = Array.length a = 1 && a.(0) = 0
|
||||||
|
|
||||||
|
let cmp (a : bn) (b : bn) : int =
|
||||||
|
let a = norm a and b = norm b in
|
||||||
|
let la = Array.length a and lb = Array.length b in
|
||||||
|
if la <> lb then compare la lb
|
||||||
|
else begin
|
||||||
|
let r = ref 0 and i = ref (la - 1) in
|
||||||
|
while !r = 0 && !i >= 0 do
|
||||||
|
if a.(!i) <> b.(!i) then r := compare a.(!i) b.(!i);
|
||||||
|
decr i
|
||||||
|
done; !r
|
||||||
|
end
|
||||||
|
|
||||||
|
let add (a : bn) (b : bn) : bn =
|
||||||
|
let la = Array.length a and lb = Array.length b in
|
||||||
|
let n = (max la lb) + 1 in
|
||||||
|
let r = Array.make n 0 in
|
||||||
|
let carry = ref 0 in
|
||||||
|
for i = 0 to n - 1 do
|
||||||
|
let s = !carry
|
||||||
|
+ (if i < la then a.(i) else 0)
|
||||||
|
+ (if i < lb then b.(i) else 0) in
|
||||||
|
r.(i) <- s land mask; carry := s lsr bits
|
||||||
|
done;
|
||||||
|
norm r
|
||||||
|
|
||||||
|
(* a - b, requires a >= b *)
|
||||||
|
let sub (a : bn) (b : bn) : bn =
|
||||||
|
let la = Array.length a and lb = Array.length b in
|
||||||
|
let r = Array.make la 0 in
|
||||||
|
let borrow = ref 0 in
|
||||||
|
for i = 0 to la - 1 do
|
||||||
|
let s = a.(i) - !borrow - (if i < lb then b.(i) else 0) in
|
||||||
|
if s < 0 then (r.(i) <- s + base; borrow := 1)
|
||||||
|
else (r.(i) <- s; borrow := 0)
|
||||||
|
done;
|
||||||
|
norm r
|
||||||
|
|
||||||
|
let mul (a : bn) (b : bn) : bn =
|
||||||
|
let la = Array.length a and lb = Array.length b in
|
||||||
|
let r = Array.make (la + lb) 0 in
|
||||||
|
for i = 0 to la - 1 do
|
||||||
|
let carry = ref 0 in
|
||||||
|
for j = 0 to lb - 1 do
|
||||||
|
let s = r.(i + j) + a.(i) * b.(j) + !carry in
|
||||||
|
r.(i + j) <- s land mask; carry := s lsr bits
|
||||||
|
done;
|
||||||
|
r.(i + lb) <- r.(i + lb) + !carry
|
||||||
|
done;
|
||||||
|
norm r
|
||||||
|
|
||||||
|
let numbits (a : bn) : int =
|
||||||
|
let a = norm a in
|
||||||
|
let hi = Array.length a - 1 in
|
||||||
|
if hi = 0 && a.(0) = 0 then 0
|
||||||
|
else begin
|
||||||
|
let b = ref 0 and v = ref a.(hi) in
|
||||||
|
while !v > 0 do incr b; v := !v lsr 1 done;
|
||||||
|
hi * bits + !b
|
||||||
|
end
|
||||||
|
|
||||||
|
let bit (a : bn) (i : int) : int =
|
||||||
|
let limb = i / bits and off = i mod bits in
|
||||||
|
if limb >= Array.length a then 0 else (a.(limb) lsr off) land 1
|
||||||
|
|
||||||
|
(* r = a mod m (m > 0), binary long division. *)
|
||||||
|
let bn_mod (a : bn) (m : bn) : bn =
|
||||||
|
if cmp a m < 0 then norm a
|
||||||
|
else begin
|
||||||
|
let r = ref bzero in
|
||||||
|
for i = numbits a - 1 downto 0 do
|
||||||
|
(* r = r*2 + bit *)
|
||||||
|
r := add !r !r;
|
||||||
|
if bit a i = 1 then r := add !r [| 1 |];
|
||||||
|
if cmp !r m >= 0 then r := sub !r m
|
||||||
|
done;
|
||||||
|
!r
|
||||||
|
end
|
||||||
|
|
||||||
|
let div_small (a : bn) (d : int) : bn =
|
||||||
|
let la = Array.length a in
|
||||||
|
let q = Array.make la 0 in
|
||||||
|
let rem = ref 0 in
|
||||||
|
for i = la - 1 downto 0 do
|
||||||
|
let cur = (!rem lsl bits) lor a.(i) in
|
||||||
|
q.(i) <- cur / d; rem := cur mod d
|
||||||
|
done;
|
||||||
|
norm q
|
||||||
|
|
||||||
|
let powmod (b0 : bn) (e : bn) (m : bn) : bn =
|
||||||
|
let result = ref [| 1 |] and b = ref (bn_mod b0 m) in
|
||||||
|
let nb = numbits e in
|
||||||
|
for i = 0 to nb - 1 do
|
||||||
|
if bit e i = 1 then result := bn_mod (mul !result !b) m;
|
||||||
|
b := bn_mod (mul !b !b) m
|
||||||
|
done;
|
||||||
|
!result
|
||||||
|
|
||||||
|
let of_bytes_le (s : string) : bn =
|
||||||
|
let acc = ref bzero in
|
||||||
|
for i = String.length s - 1 downto 0 do
|
||||||
|
acc := add (mul !acc (of_int 256)) (of_int (Char.code s.[i]))
|
||||||
|
done;
|
||||||
|
!acc
|
||||||
|
|
||||||
|
let to_bytes_le (a : bn) (n : int) : string =
|
||||||
|
let b = Bytes.make n '\000' in
|
||||||
|
let cur = ref (norm a) in
|
||||||
|
for i = 0 to n - 1 do
|
||||||
|
let q = div_small !cur 256 in
|
||||||
|
let r =
|
||||||
|
let qm = mul q (of_int 256) in
|
||||||
|
let d = sub !cur qm in
|
||||||
|
if is_zero d then 0 else d.(0)
|
||||||
|
in
|
||||||
|
Bytes.set b i (Char.chr r);
|
||||||
|
cur := q
|
||||||
|
done;
|
||||||
|
Bytes.unsafe_to_string b
|
||||||
|
|
||||||
|
(* ---- Field GF(p), p = 2^255 - 19 ---- *)
|
||||||
|
|
||||||
|
let p =
|
||||||
|
let twop255 = Array.make 11 0 in (* 11*26 = 286 > 255 *)
|
||||||
|
let limb = 255 / bits and off = 255 mod bits in
|
||||||
|
twop255.(limb) <- 1 lsl off;
|
||||||
|
sub (norm twop255) (of_int 19)
|
||||||
|
|
||||||
|
let fmod a = bn_mod a p
|
||||||
|
let fadd a b = fmod (add a b)
|
||||||
|
let fsub a b = fmod (add a (sub p (fmod b)))
|
||||||
|
let fmul a b = fmod (mul a b)
|
||||||
|
let fpow a e = powmod a e p
|
||||||
|
let finv a = fpow a (sub p (of_int 2)) (* Fermat: a^(p-2) *)
|
||||||
|
|
||||||
|
(* group order L = 2^252 + 27742317777372353535851937790883648493 *)
|
||||||
|
let ell =
|
||||||
|
of_bytes_le
|
||||||
|
"\xed\xd3\xf5\x5c\x1a\x63\x12\x58\xd6\x9c\xf7\xa2\xde\xf9\xde\x14\
|
||||||
|
\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10"
|
||||||
|
|
||||||
|
(* d = -121665 / 121666 mod p *)
|
||||||
|
let dconst =
|
||||||
|
let inv666 = finv (of_int 121666) in
|
||||||
|
fmod (mul (fsub (of_int 0) (of_int 121665)) inv666)
|
||||||
|
|
||||||
|
(* sqrt(-1) = 2^((p-1)/4) mod p *)
|
||||||
|
let sqrtm1 = fpow (of_int 2) (div_small (sub p (of_int 1)) 4)
|
||||||
|
|
||||||
|
(* ---- edwards25519 points in extended coords (X,Y,Z,T) ---- *)
|
||||||
|
|
||||||
|
type pt = { x : bn; y : bn; z : bn; t : bn }
|
||||||
|
|
||||||
|
let identity = { x = bzero; y = of_int 1; z = of_int 1; t = bzero }
|
||||||
|
|
||||||
|
(* add-2008-hwcd-3, complete for a = -1 on ed25519 *)
|
||||||
|
let padd (p1 : pt) (p2 : pt) : pt =
|
||||||
|
let a = fmul (fsub p1.y p1.x) (fsub p2.y p2.x) in
|
||||||
|
let b = fmul (fadd p1.y p1.x) (fadd p2.y p2.x) in
|
||||||
|
let c = fmul (fmul p1.t (fmul (of_int 2) dconst)) p2.t in
|
||||||
|
let dd = fmul (fmul p1.z (of_int 2)) p2.z in
|
||||||
|
let e = fsub b a in
|
||||||
|
let f = fsub dd c in
|
||||||
|
let g = fadd dd c in
|
||||||
|
let h = fadd b a in
|
||||||
|
{ x = fmul e f; y = fmul g h; t = fmul e h; z = fmul f g }
|
||||||
|
|
||||||
|
let scalar_mul (n : bn) (q : pt) : pt =
|
||||||
|
let r = ref identity in
|
||||||
|
for i = numbits n - 1 downto 0 do
|
||||||
|
r := padd !r !r;
|
||||||
|
if bit n i = 1 then r := padd !r q
|
||||||
|
done;
|
||||||
|
!r
|
||||||
|
|
||||||
|
let pnegate (q : pt) : pt =
|
||||||
|
{ q with x = fsub (of_int 0) q.x; t = fsub (of_int 0) q.t }
|
||||||
|
|
||||||
|
(* Decompress a 32-byte little-endian point encoding. *)
|
||||||
|
let decompress (s : string) : pt option =
|
||||||
|
if String.length s <> 32 then None
|
||||||
|
else begin
|
||||||
|
let sign = (Char.code s.[31] lsr 7) land 1 in
|
||||||
|
let s' = Bytes.of_string s in
|
||||||
|
Bytes.set s' 31 (Char.chr (Char.code s.[31] land 0x7f));
|
||||||
|
let y = of_bytes_le (Bytes.unsafe_to_string s') in
|
||||||
|
if cmp y p >= 0 then None
|
||||||
|
else begin
|
||||||
|
let y2 = fmul y y in
|
||||||
|
let u = fsub y2 (of_int 1) in
|
||||||
|
let v = fadd (fmul dconst y2) (of_int 1) in
|
||||||
|
(* x = u v^3 (u v^7)^((p-5)/8) *)
|
||||||
|
let v3 = fmul (fmul v v) v in
|
||||||
|
let v7 = fmul (fmul v3 v3) v in
|
||||||
|
let exp = div_small (sub p (of_int 5)) 8 in
|
||||||
|
let x0 = fmul (fmul u v3) (fpow (fmul u v7) exp) in
|
||||||
|
let vx2 = fmul v (fmul x0 x0) in
|
||||||
|
let x =
|
||||||
|
if cmp vx2 u = 0 then Some x0
|
||||||
|
else if cmp vx2 (fsub (of_int 0) u) = 0 then Some (fmul x0 sqrtm1)
|
||||||
|
else None
|
||||||
|
in
|
||||||
|
match x with
|
||||||
|
| None -> None
|
||||||
|
| Some x ->
|
||||||
|
if is_zero x && sign = 1 then None
|
||||||
|
else begin
|
||||||
|
let x = if (bit x 0) <> sign then fsub (of_int 0) x else x in
|
||||||
|
Some { x; y; z = of_int 1; t = fmul x y }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
(* Encode a point to 32-byte little-endian (y with x-parity bit). *)
|
||||||
|
let encode (q : pt) : string =
|
||||||
|
let zi = finv q.z in
|
||||||
|
let x = fmul q.x zi and y = fmul q.y zi in
|
||||||
|
let b = Bytes.of_string (to_bytes_le y 32) in
|
||||||
|
let last = Char.code (Bytes.get b 31) lor ((bit x 0) lsl 7) in
|
||||||
|
Bytes.set b 31 (Char.chr last);
|
||||||
|
Bytes.unsafe_to_string b
|
||||||
|
|
||||||
|
(* base point: y = 4/5 mod p, x even (sign 0). *)
|
||||||
|
let base_point =
|
||||||
|
let by = fmul (of_int 4) (finv (of_int 5)) in
|
||||||
|
match decompress (to_bytes_le by 32) with
|
||||||
|
| Some pt -> pt
|
||||||
|
| None -> failwith "ed25519: base point decompress failed"
|
||||||
|
|
||||||
|
let unhex (h : string) : string =
|
||||||
|
let n = String.length h / 2 in
|
||||||
|
let b = Bytes.create n in
|
||||||
|
for i = 0 to n - 1 do
|
||||||
|
Bytes.set b i
|
||||||
|
(Char.chr (int_of_string ("0x" ^ String.sub h (2 * i) 2)))
|
||||||
|
done;
|
||||||
|
Bytes.unsafe_to_string b
|
||||||
|
|
||||||
|
let sha512_bytes s = unhex (Sx_sha2.sha512_hex s)
|
||||||
|
|
||||||
|
(* RFC 8032 §5.1.7 cofactorless: encode([S]B - [k]A) == R. *)
|
||||||
|
let verify ~pubkey ~msg ~sig_ : bool =
|
||||||
|
if String.length pubkey <> 32 || String.length sig_ <> 64 then false
|
||||||
|
else
|
||||||
|
let rb = String.sub sig_ 0 32 in
|
||||||
|
let sb = String.sub sig_ 32 32 in
|
||||||
|
let s = of_bytes_le sb in
|
||||||
|
if cmp s ell >= 0 then false
|
||||||
|
else
|
||||||
|
match decompress pubkey with
|
||||||
|
| None -> false
|
||||||
|
| Some a ->
|
||||||
|
let h = sha512_bytes (rb ^ pubkey ^ msg) in
|
||||||
|
let k = bn_mod (of_bytes_le h) ell in
|
||||||
|
let sb_pt = scalar_mul s base_point in
|
||||||
|
let ka = scalar_mul k a in
|
||||||
|
let chk = padd sb_pt (pnegate ka) in
|
||||||
|
(try encode chk = rb with _ -> false)
|
||||||
@@ -3237,6 +3237,21 @@ let () =
|
|||||||
with Sys_error msg -> raise (Eval_error ("file-read: " ^ msg)))
|
with Sys_error msg -> raise (Eval_error ("file-read: " ^ msg)))
|
||||||
| _ -> raise (Eval_error "file-read: (path)"));
|
| _ -> raise (Eval_error "file-read: (path)"));
|
||||||
|
|
||||||
|
(* fed-sx Step 3 segment replay. Sorted names, no "."/".." ;
|
||||||
|
errors prefixed like file-read (msg carries enoent/enotdir). *)
|
||||||
|
register "file-list-dir" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [String path] ->
|
||||||
|
(try
|
||||||
|
let names = Sys.readdir path in
|
||||||
|
let names =
|
||||||
|
Array.to_list names
|
||||||
|
|> List.filter (fun n -> n <> "." && n <> "..") in
|
||||||
|
let names = List.sort compare names in
|
||||||
|
List (List.map (fun n -> String n) names)
|
||||||
|
with Sys_error msg -> raise (Eval_error ("file-list-dir: " ^ msg)))
|
||||||
|
| _ -> raise (Eval_error "file-list-dir: (path)"));
|
||||||
|
|
||||||
register "file-write" (fun args ->
|
register "file-write" (fun args ->
|
||||||
match args with
|
match args with
|
||||||
| [String path; String content] ->
|
| [String path; String content] ->
|
||||||
@@ -4158,4 +4173,61 @@ let () =
|
|||||||
Sx_types.jit_skipped_count := 0;
|
Sx_types.jit_skipped_count := 0;
|
||||||
Sx_types.jit_threshold_skipped_count := 0;
|
Sx_types.jit_threshold_skipped_count := 0;
|
||||||
Sx_types.jit_evicted_count := 0;
|
Sx_types.jit_evicted_count := 0;
|
||||||
Nil)
|
Nil);
|
||||||
|
|
||||||
|
(* fed-sx host primitives — pure-OCaml crypto (WASM-safe). *)
|
||||||
|
register "crypto-sha256" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [String s] -> String (Sx_sha2.sha256_hex s)
|
||||||
|
| _ -> raise (Eval_error "crypto-sha256: (bytes)"));
|
||||||
|
|
||||||
|
register "crypto-sha512" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [String s] -> String (Sx_sha2.sha512_hex s)
|
||||||
|
| _ -> raise (Eval_error "crypto-sha512: (bytes)"));
|
||||||
|
|
||||||
|
register "crypto-sha3-256" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [String s] -> String (Sx_sha3.sha3_256_hex s)
|
||||||
|
| _ -> raise (Eval_error "crypto-sha3-256: (bytes)"));
|
||||||
|
|
||||||
|
register "cbor-encode" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [v] ->
|
||||||
|
(try String (Sx_cbor.encode v)
|
||||||
|
with Sx_cbor.Cbor_error m -> raise (Eval_error m))
|
||||||
|
| _ -> raise (Eval_error "cbor-encode: (value)"));
|
||||||
|
|
||||||
|
register "cbor-decode" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [String s] ->
|
||||||
|
(try Sx_cbor.decode s
|
||||||
|
with Sx_cbor.Cbor_error m -> raise (Eval_error m))
|
||||||
|
| _ -> raise (Eval_error "cbor-decode: (bytes)"));
|
||||||
|
|
||||||
|
register "cid-from-bytes" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [Integer codec; String mh] ->
|
||||||
|
String (Sx_cid.cidv1 codec mh)
|
||||||
|
| _ -> raise (Eval_error "cid-from-bytes: (codec multihash-bytes)"));
|
||||||
|
|
||||||
|
register "cid-from-sx" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [v] ->
|
||||||
|
(try String (Sx_cid.cid_from_sx v)
|
||||||
|
with Sx_cbor.Cbor_error m -> raise (Eval_error m))
|
||||||
|
| _ -> raise (Eval_error "cid-from-sx: (value)"));
|
||||||
|
|
||||||
|
(* Verify is total: any malformed input -> false, never raises. *)
|
||||||
|
register "ed25519-verify" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [String pk; String msg; String sg] ->
|
||||||
|
Bool (try Sx_ed25519.verify ~pubkey:pk ~msg ~sig_:sg
|
||||||
|
with _ -> false)
|
||||||
|
| _ -> Bool false);
|
||||||
|
|
||||||
|
register "rsa-sha256-verify" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [String spki; String msg; String sg] ->
|
||||||
|
Bool (try Sx_rsa.verify ~spki ~msg ~sig_:sg with _ -> false)
|
||||||
|
| _ -> Bool false)
|
||||||
|
|||||||
220
hosts/ocaml/lib/sx_rsa.ml
Normal file
220
hosts/ocaml/lib/sx_rsa.ml
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
(** RSASSA-PKCS1-v1_5 verification with SHA-256 — pure OCaml,
|
||||||
|
WASM-safe. Self-contained minimal bignum (modexp only), a tiny
|
||||||
|
DER reader for SubjectPublicKeyInfo, and the fixed SHA-256
|
||||||
|
DigestInfo prefix. Verify only on public data — constant time
|
||||||
|
not required. Reference: RFC 8017 §8.2.2, §9.2. No deps. *)
|
||||||
|
|
||||||
|
(* ---- Minimal unsigned bignum: int array, little-endian, base 2^26 ---- *)
|
||||||
|
|
||||||
|
let bits = 26
|
||||||
|
let base = 1 lsl bits
|
||||||
|
let mask = base - 1
|
||||||
|
|
||||||
|
type bn = int array
|
||||||
|
|
||||||
|
let norm a =
|
||||||
|
let n = ref (Array.length a) in
|
||||||
|
while !n > 1 && a.(!n - 1) = 0 do decr n done;
|
||||||
|
if !n = Array.length a then a else Array.sub a 0 !n
|
||||||
|
|
||||||
|
let bzero : bn = [| 0 |]
|
||||||
|
let is_zero a = Array.length a = 1 && a.(0) = 0
|
||||||
|
|
||||||
|
let cmp a b =
|
||||||
|
let a = norm a and b = norm b in
|
||||||
|
let la = Array.length a and lb = Array.length b in
|
||||||
|
if la <> lb then compare la lb
|
||||||
|
else begin
|
||||||
|
let r = ref 0 and i = ref (la - 1) in
|
||||||
|
while !r = 0 && !i >= 0 do
|
||||||
|
if a.(!i) <> b.(!i) then r := compare a.(!i) b.(!i);
|
||||||
|
decr i
|
||||||
|
done; !r
|
||||||
|
end
|
||||||
|
|
||||||
|
let add a b =
|
||||||
|
let la = Array.length a and lb = Array.length b in
|
||||||
|
let n = (max la lb) + 1 in
|
||||||
|
let r = Array.make n 0 and carry = ref 0 in
|
||||||
|
for i = 0 to n - 1 do
|
||||||
|
let s = !carry + (if i < la then a.(i) else 0)
|
||||||
|
+ (if i < lb then b.(i) else 0) in
|
||||||
|
r.(i) <- s land mask; carry := s lsr bits
|
||||||
|
done;
|
||||||
|
norm r
|
||||||
|
|
||||||
|
let sub a b = (* requires a >= b *)
|
||||||
|
let la = Array.length a and lb = Array.length b in
|
||||||
|
let r = Array.make la 0 and borrow = ref 0 in
|
||||||
|
for i = 0 to la - 1 do
|
||||||
|
let s = a.(i) - !borrow - (if i < lb then b.(i) else 0) in
|
||||||
|
if s < 0 then (r.(i) <- s + base; borrow := 1)
|
||||||
|
else (r.(i) <- s; borrow := 0)
|
||||||
|
done;
|
||||||
|
norm r
|
||||||
|
|
||||||
|
let mul a b =
|
||||||
|
let la = Array.length a and lb = Array.length b in
|
||||||
|
let r = Array.make (la + lb) 0 in
|
||||||
|
for i = 0 to la - 1 do
|
||||||
|
let carry = ref 0 in
|
||||||
|
for j = 0 to lb - 1 do
|
||||||
|
let s = r.(i + j) + a.(i) * b.(j) + !carry in
|
||||||
|
r.(i + j) <- s land mask; carry := s lsr bits
|
||||||
|
done;
|
||||||
|
r.(i + lb) <- r.(i + lb) + !carry
|
||||||
|
done;
|
||||||
|
norm r
|
||||||
|
|
||||||
|
let numbits a =
|
||||||
|
let a = norm a in
|
||||||
|
let hi = Array.length a - 1 in
|
||||||
|
if hi = 0 && a.(0) = 0 then 0
|
||||||
|
else begin
|
||||||
|
let b = ref 0 and v = ref a.(hi) in
|
||||||
|
while !v > 0 do incr b; v := !v lsr 1 done;
|
||||||
|
hi * bits + !b
|
||||||
|
end
|
||||||
|
|
||||||
|
let bit a i =
|
||||||
|
let limb = i / bits and off = i mod bits in
|
||||||
|
if limb >= Array.length a then 0 else (a.(limb) lsr off) land 1
|
||||||
|
|
||||||
|
let bn_mod a m = (* binary long division, m > 0 *)
|
||||||
|
if cmp a m < 0 then norm a
|
||||||
|
else begin
|
||||||
|
let r = ref bzero in
|
||||||
|
for i = numbits a - 1 downto 0 do
|
||||||
|
r := add !r !r;
|
||||||
|
if bit a i = 1 then r := add !r [| 1 |];
|
||||||
|
if cmp !r m >= 0 then r := sub !r m
|
||||||
|
done;
|
||||||
|
!r
|
||||||
|
end
|
||||||
|
|
||||||
|
let powmod b0 e m =
|
||||||
|
let result = ref [| 1 |] and b = ref (bn_mod b0 m) in
|
||||||
|
for i = 0 to numbits e - 1 do
|
||||||
|
if bit e i = 1 then result := bn_mod (mul !result !b) m;
|
||||||
|
b := bn_mod (mul !b !b) m
|
||||||
|
done;
|
||||||
|
!result
|
||||||
|
|
||||||
|
let of_bytes_be (s : string) : bn =
|
||||||
|
let acc = ref bzero in
|
||||||
|
for i = 0 to String.length s - 1 do
|
||||||
|
acc := add (mul !acc [| 256 |]) [| Char.code s.[i] |]
|
||||||
|
done;
|
||||||
|
!acc
|
||||||
|
|
||||||
|
let div_small a d =
|
||||||
|
let la = Array.length a in
|
||||||
|
let q = Array.make la 0 and rem = ref 0 in
|
||||||
|
for i = la - 1 downto 0 do
|
||||||
|
let cur = (!rem lsl bits) lor a.(i) in
|
||||||
|
q.(i) <- cur / d; rem := cur mod d
|
||||||
|
done;
|
||||||
|
norm q
|
||||||
|
|
||||||
|
let to_bytes_be (a : bn) (n : int) : string =
|
||||||
|
let b = Bytes.make n '\000' in
|
||||||
|
let cur = ref (norm a) in
|
||||||
|
for i = n - 1 downto 0 do
|
||||||
|
let q = div_small !cur 256 in
|
||||||
|
let r =
|
||||||
|
let d = sub !cur (mul q [| 256 |]) in
|
||||||
|
if is_zero d then 0 else d.(0)
|
||||||
|
in
|
||||||
|
Bytes.set b i (Char.chr r);
|
||||||
|
cur := q
|
||||||
|
done;
|
||||||
|
Bytes.unsafe_to_string b
|
||||||
|
|
||||||
|
(* ---- Minimal DER reader (for SubjectPublicKeyInfo) ---- *)
|
||||||
|
|
||||||
|
exception Der of string
|
||||||
|
|
||||||
|
(* Returns (tag, content_start, content_len, next). *)
|
||||||
|
let der_tlv s pos =
|
||||||
|
if pos + 2 > String.length s then raise (Der "short");
|
||||||
|
let tag = Char.code s.[pos] in
|
||||||
|
let l0 = Char.code s.[pos + 1] in
|
||||||
|
let len, hdr =
|
||||||
|
if l0 < 0x80 then l0, 2
|
||||||
|
else begin
|
||||||
|
let nb = l0 land 0x7f in
|
||||||
|
if pos + 2 + nb > String.length s then raise (Der "short len");
|
||||||
|
let v = ref 0 in
|
||||||
|
for i = 0 to nb - 1 do
|
||||||
|
v := (!v lsl 8) lor Char.code s.[pos + 2 + i]
|
||||||
|
done;
|
||||||
|
!v, 2 + nb
|
||||||
|
end
|
||||||
|
in
|
||||||
|
(tag, pos + hdr, len, pos + hdr + len)
|
||||||
|
|
||||||
|
(* SPKI DER -> (n, e) as bignums. *)
|
||||||
|
let parse_spki (der : string) : bn * bn =
|
||||||
|
let tag, c, _l, _ = der_tlv der 0 in
|
||||||
|
if tag <> 0x30 then raise (Der "spki: outer not SEQUENCE");
|
||||||
|
(* AlgorithmIdentifier SEQUENCE — skip. *)
|
||||||
|
let _, _, _, after_alg = der_tlv der c in
|
||||||
|
(* BIT STRING. *)
|
||||||
|
let bt, bc, bl, _ = der_tlv der after_alg in
|
||||||
|
if bt <> 0x03 then raise (Der "spki: expected BIT STRING");
|
||||||
|
(* First content byte = unused bits (must be 0). *)
|
||||||
|
let rpk_start = bc + 1 in
|
||||||
|
ignore bl;
|
||||||
|
let st, sc, _, _ = der_tlv der rpk_start in
|
||||||
|
if st <> 0x30 then raise (Der "spki: RSAPublicKey not SEQUENCE");
|
||||||
|
let nt, nc, nl, after_n = der_tlv der sc in
|
||||||
|
if nt <> 0x02 then raise (Der "spki: modulus not INTEGER");
|
||||||
|
let et, ec, el, _ = der_tlv der after_n in
|
||||||
|
if et <> 0x02 then raise (Der "spki: exponent not INTEGER");
|
||||||
|
let n = of_bytes_be (String.sub der nc nl) in
|
||||||
|
let e = of_bytes_be (String.sub der ec el) in
|
||||||
|
(n, e)
|
||||||
|
|
||||||
|
(* SHA-256 DigestInfo DER prefix (RFC 8017 §9.2 note 1). *)
|
||||||
|
let sha256_digestinfo_prefix =
|
||||||
|
"\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20"
|
||||||
|
|
||||||
|
let unhex h =
|
||||||
|
let n = String.length h / 2 in
|
||||||
|
let b = Bytes.create n in
|
||||||
|
for i = 0 to n - 1 do
|
||||||
|
Bytes.set b i (Char.chr (int_of_string ("0x" ^ String.sub h (2 * i) 2)))
|
||||||
|
done;
|
||||||
|
Bytes.unsafe_to_string b
|
||||||
|
|
||||||
|
(* RSASSA-PKCS1-v1_5 verify with SHA-256. Total: any malformed
|
||||||
|
input yields false (caller wraps, but be defensive here too). *)
|
||||||
|
let verify ~spki ~msg ~sig_ : bool =
|
||||||
|
try
|
||||||
|
let n, e = parse_spki spki in
|
||||||
|
let k = (numbits n + 7) / 8 in
|
||||||
|
if String.length sig_ <> k then false
|
||||||
|
else begin
|
||||||
|
let s = of_bytes_be sig_ in
|
||||||
|
if cmp s n >= 0 then false
|
||||||
|
else begin
|
||||||
|
let m = powmod s e n in
|
||||||
|
let em = to_bytes_be m k in
|
||||||
|
(* EM = 0x00 01 FF..FF 00 || DigestInfo || H *)
|
||||||
|
let h = unhex (Sx_sha2.sha256_hex msg) in
|
||||||
|
let t = sha256_digestinfo_prefix ^ h in
|
||||||
|
let tlen = String.length t in
|
||||||
|
if k < tlen + 11 then false
|
||||||
|
else begin
|
||||||
|
let ok = ref (em.[0] = '\x00' && em.[1] = '\x01') in
|
||||||
|
let ps_end = k - tlen - 1 in
|
||||||
|
for i = 2 to ps_end - 1 do
|
||||||
|
if em.[i] <> '\xff' then ok := false
|
||||||
|
done;
|
||||||
|
if em.[ps_end] <> '\x00' then ok := false;
|
||||||
|
if String.sub em (ps_end + 1) tlen <> t then ok := false;
|
||||||
|
!ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
with _ -> false
|
||||||
212
hosts/ocaml/lib/sx_sha2.ml
Normal file
212
hosts/ocaml/lib/sx_sha2.ml
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
(** SHA-2 (SHA-256, SHA-512) — pure OCaml, WASM-safe.
|
||||||
|
|
||||||
|
No C stubs, no external deps. Used by the fed-sx host primitives
|
||||||
|
[crypto-sha256] / [crypto-sha512]. Reference: FIPS 180-4. *)
|
||||||
|
|
||||||
|
(* ---- SHA-256 (FIPS 180-4 §6.2). 32-bit words held in native int,
|
||||||
|
masked to 32 bits after every arithmetic op. ---- *)
|
||||||
|
|
||||||
|
let mask32 = 0xFFFFFFFF
|
||||||
|
|
||||||
|
let k256 = [|
|
||||||
|
0x428a2f98; 0x71374491; 0xb5c0fbcf; 0xe9b5dba5;
|
||||||
|
0x3956c25b; 0x59f111f1; 0x923f82a4; 0xab1c5ed5;
|
||||||
|
0xd807aa98; 0x12835b01; 0x243185be; 0x550c7dc3;
|
||||||
|
0x72be5d74; 0x80deb1fe; 0x9bdc06a7; 0xc19bf174;
|
||||||
|
0xe49b69c1; 0xefbe4786; 0x0fc19dc6; 0x240ca1cc;
|
||||||
|
0x2de92c6f; 0x4a7484aa; 0x5cb0a9dc; 0x76f988da;
|
||||||
|
0x983e5152; 0xa831c66d; 0xb00327c8; 0xbf597fc7;
|
||||||
|
0xc6e00bf3; 0xd5a79147; 0x06ca6351; 0x14292967;
|
||||||
|
0x27b70a85; 0x2e1b2138; 0x4d2c6dfc; 0x53380d13;
|
||||||
|
0x650a7354; 0x766a0abb; 0x81c2c92e; 0x92722c85;
|
||||||
|
0xa2bfe8a1; 0xa81a664b; 0xc24b8b70; 0xc76c51a3;
|
||||||
|
0xd192e819; 0xd6990624; 0xf40e3585; 0x106aa070;
|
||||||
|
0x19a4c116; 0x1e376c08; 0x2748774c; 0x34b0bcb5;
|
||||||
|
0x391c0cb3; 0x4ed8aa4a; 0x5b9cca4f; 0x682e6ff3;
|
||||||
|
0x748f82ee; 0x78a5636f; 0x84c87814; 0x8cc70208;
|
||||||
|
0x90befffa; 0xa4506ceb; 0xbef9a3f7; 0xc67178f2 |]
|
||||||
|
|
||||||
|
let rotr32 x n = ((x lsr n) lor (x lsl (32 - n))) land mask32
|
||||||
|
|
||||||
|
let sha256_hex (msg : string) : string =
|
||||||
|
let h = [| 0x6a09e667; 0xbb67ae85; 0x3c6ef372; 0xa54ff53a;
|
||||||
|
0x510e527f; 0x9b05688c; 0x1f83d9ab; 0x5be0cd19 |] in
|
||||||
|
let len = String.length msg in
|
||||||
|
(* Padded length: multiple of 64 bytes. *)
|
||||||
|
let bitlen = len * 8 in
|
||||||
|
let padlen =
|
||||||
|
let r = (len + 1) mod 64 in
|
||||||
|
if r <= 56 then 56 - r else 120 - r
|
||||||
|
in
|
||||||
|
let total = len + 1 + padlen + 8 in
|
||||||
|
let buf = Bytes.make total '\000' in
|
||||||
|
Bytes.blit_string msg 0 buf 0 len;
|
||||||
|
Bytes.set buf len '\x80';
|
||||||
|
(* 64-bit big-endian bit length (we cap at OCaml int range). *)
|
||||||
|
for i = 0 to 7 do
|
||||||
|
Bytes.set buf (total - 1 - i)
|
||||||
|
(Char.chr ((bitlen lsr (8 * i)) land 0xFF))
|
||||||
|
done;
|
||||||
|
let w = Array.make 64 0 in
|
||||||
|
let nblocks = total / 64 in
|
||||||
|
for b = 0 to nblocks - 1 do
|
||||||
|
let base = b * 64 in
|
||||||
|
for t = 0 to 15 do
|
||||||
|
let o = base + t * 4 in
|
||||||
|
w.(t) <-
|
||||||
|
(Char.code (Bytes.get buf o) lsl 24)
|
||||||
|
lor (Char.code (Bytes.get buf (o + 1)) lsl 16)
|
||||||
|
lor (Char.code (Bytes.get buf (o + 2)) lsl 8)
|
||||||
|
lor (Char.code (Bytes.get buf (o + 3)))
|
||||||
|
done;
|
||||||
|
for t = 16 to 63 do
|
||||||
|
let s0 =
|
||||||
|
(rotr32 w.(t - 15) 7) lxor (rotr32 w.(t - 15) 18)
|
||||||
|
lxor (w.(t - 15) lsr 3) in
|
||||||
|
let s1 =
|
||||||
|
(rotr32 w.(t - 2) 17) lxor (rotr32 w.(t - 2) 19)
|
||||||
|
lxor (w.(t - 2) lsr 10) in
|
||||||
|
w.(t) <- (w.(t - 16) + s0 + w.(t - 7) + s1) land mask32
|
||||||
|
done;
|
||||||
|
let a = ref h.(0) and bb = ref h.(1) and c = ref h.(2)
|
||||||
|
and d = ref h.(3) and e = ref h.(4) and f = ref h.(5)
|
||||||
|
and g = ref h.(6) and hh = ref h.(7) in
|
||||||
|
for t = 0 to 63 do
|
||||||
|
let s1 =
|
||||||
|
(rotr32 !e 6) lxor (rotr32 !e 11) lxor (rotr32 !e 25) in
|
||||||
|
let ch = (!e land !f) lxor ((lnot !e land mask32) land !g) in
|
||||||
|
let t1 = (!hh + s1 + ch + k256.(t) + w.(t)) land mask32 in
|
||||||
|
let s0 =
|
||||||
|
(rotr32 !a 2) lxor (rotr32 !a 13) lxor (rotr32 !a 22) in
|
||||||
|
let maj = (!a land !bb) lxor (!a land !c) lxor (!bb land !c) in
|
||||||
|
let t2 = (s0 + maj) land mask32 in
|
||||||
|
hh := !g; g := !f; f := !e;
|
||||||
|
e := (!d + t1) land mask32;
|
||||||
|
d := !c; c := !bb; bb := !a;
|
||||||
|
a := (t1 + t2) land mask32
|
||||||
|
done;
|
||||||
|
h.(0) <- (h.(0) + !a) land mask32;
|
||||||
|
h.(1) <- (h.(1) + !bb) land mask32;
|
||||||
|
h.(2) <- (h.(2) + !c) land mask32;
|
||||||
|
h.(3) <- (h.(3) + !d) land mask32;
|
||||||
|
h.(4) <- (h.(4) + !e) land mask32;
|
||||||
|
h.(5) <- (h.(5) + !f) land mask32;
|
||||||
|
h.(6) <- (h.(6) + !g) land mask32;
|
||||||
|
h.(7) <- (h.(7) + !hh) land mask32
|
||||||
|
done;
|
||||||
|
let out = Buffer.create 64 in
|
||||||
|
Array.iter (fun x -> Buffer.add_string out (Printf.sprintf "%08x" x)) h;
|
||||||
|
Buffer.contents out
|
||||||
|
|
||||||
|
(* ---- SHA-512 (FIPS 180-4 §6.4). 64-bit words via Int64.
|
||||||
|
128-bit length append; we only support messages whose bit length
|
||||||
|
fits in 64 bits (high word is always zero). ---- *)
|
||||||
|
|
||||||
|
let k512 = [|
|
||||||
|
0x428a2f98d728ae22L; 0x7137449123ef65cdL; 0xb5c0fbcfec4d3b2fL;
|
||||||
|
0xe9b5dba58189dbbcL; 0x3956c25bf348b538L; 0x59f111f1b605d019L;
|
||||||
|
0x923f82a4af194f9bL; 0xab1c5ed5da6d8118L; 0xd807aa98a3030242L;
|
||||||
|
0x12835b0145706fbeL; 0x243185be4ee4b28cL; 0x550c7dc3d5ffb4e2L;
|
||||||
|
0x72be5d74f27b896fL; 0x80deb1fe3b1696b1L; 0x9bdc06a725c71235L;
|
||||||
|
0xc19bf174cf692694L; 0xe49b69c19ef14ad2L; 0xefbe4786384f25e3L;
|
||||||
|
0x0fc19dc68b8cd5b5L; 0x240ca1cc77ac9c65L; 0x2de92c6f592b0275L;
|
||||||
|
0x4a7484aa6ea6e483L; 0x5cb0a9dcbd41fbd4L; 0x76f988da831153b5L;
|
||||||
|
0x983e5152ee66dfabL; 0xa831c66d2db43210L; 0xb00327c898fb213fL;
|
||||||
|
0xbf597fc7beef0ee4L; 0xc6e00bf33da88fc2L; 0xd5a79147930aa725L;
|
||||||
|
0x06ca6351e003826fL; 0x142929670a0e6e70L; 0x27b70a8546d22ffcL;
|
||||||
|
0x2e1b21385c26c926L; 0x4d2c6dfc5ac42aedL; 0x53380d139d95b3dfL;
|
||||||
|
0x650a73548baf63deL; 0x766a0abb3c77b2a8L; 0x81c2c92e47edaee6L;
|
||||||
|
0x92722c851482353bL; 0xa2bfe8a14cf10364L; 0xa81a664bbc423001L;
|
||||||
|
0xc24b8b70d0f89791L; 0xc76c51a30654be30L; 0xd192e819d6ef5218L;
|
||||||
|
0xd69906245565a910L; 0xf40e35855771202aL; 0x106aa07032bbd1b8L;
|
||||||
|
0x19a4c116b8d2d0c8L; 0x1e376c085141ab53L; 0x2748774cdf8eeb99L;
|
||||||
|
0x34b0bcb5e19b48a8L; 0x391c0cb3c5c95a63L; 0x4ed8aa4ae3418acbL;
|
||||||
|
0x5b9cca4f7763e373L; 0x682e6ff3d6b2b8a3L; 0x748f82ee5defb2fcL;
|
||||||
|
0x78a5636f43172f60L; 0x84c87814a1f0ab72L; 0x8cc702081a6439ecL;
|
||||||
|
0x90befffa23631e28L; 0xa4506cebde82bde9L; 0xbef9a3f7b2c67915L;
|
||||||
|
0xc67178f2e372532bL; 0xca273eceea26619cL; 0xd186b8c721c0c207L;
|
||||||
|
0xeada7dd6cde0eb1eL; 0xf57d4f7fee6ed178L; 0x06f067aa72176fbaL;
|
||||||
|
0x0a637dc5a2c898a6L; 0x113f9804bef90daeL; 0x1b710b35131c471bL;
|
||||||
|
0x28db77f523047d84L; 0x32caab7b40c72493L; 0x3c9ebe0a15c9bebcL;
|
||||||
|
0x431d67c49c100d4cL; 0x4cc5d4becb3e42b6L; 0x597f299cfc657e2aL;
|
||||||
|
0x5fcb6fab3ad6faecL; 0x6c44198c4a475817L |]
|
||||||
|
|
||||||
|
let ( &: ) = Int64.logand
|
||||||
|
let ( |: ) = Int64.logor
|
||||||
|
let ( ^: ) = Int64.logxor
|
||||||
|
let ( +: ) = Int64.add
|
||||||
|
let lnot64 = Int64.lognot
|
||||||
|
|
||||||
|
let rotr64 x n =
|
||||||
|
(Int64.shift_right_logical x n) |: (Int64.shift_left x (64 - n))
|
||||||
|
|
||||||
|
let sha512_hex (msg : string) : string =
|
||||||
|
let h = [| 0x6a09e667f3bcc908L; 0xbb67ae8584caa73bL;
|
||||||
|
0x3c6ef372fe94f82bL; 0xa54ff53a5f1d36f1L;
|
||||||
|
0x510e527fade682d1L; 0x9b05688c2b3e6c1fL;
|
||||||
|
0x1f83d9abfb41bd6bL; 0x5be0cd19137e2179L |] in
|
||||||
|
let len = String.length msg in
|
||||||
|
let bitlen = len * 8 in
|
||||||
|
(* Pad to a multiple of 128 bytes; 16-byte big-endian length. *)
|
||||||
|
let padlen =
|
||||||
|
let r = (len + 1) mod 128 in
|
||||||
|
if r <= 112 then 112 - r else 240 - r
|
||||||
|
in
|
||||||
|
let total = len + 1 + padlen + 16 in
|
||||||
|
let buf = Bytes.make total '\000' in
|
||||||
|
Bytes.blit_string msg 0 buf 0 len;
|
||||||
|
Bytes.set buf len '\x80';
|
||||||
|
for i = 0 to 7 do
|
||||||
|
Bytes.set buf (total - 1 - i)
|
||||||
|
(Char.chr ((bitlen lsr (8 * i)) land 0xFF))
|
||||||
|
done;
|
||||||
|
let w = Array.make 80 0L in
|
||||||
|
let nblocks = total / 128 in
|
||||||
|
for b = 0 to nblocks - 1 do
|
||||||
|
let base = b * 128 in
|
||||||
|
for t = 0 to 15 do
|
||||||
|
let o = base + t * 8 in
|
||||||
|
let v = ref 0L in
|
||||||
|
for j = 0 to 7 do
|
||||||
|
v := Int64.logor (Int64.shift_left !v 8)
|
||||||
|
(Int64.of_int (Char.code (Bytes.get buf (o + j))))
|
||||||
|
done;
|
||||||
|
w.(t) <- !v
|
||||||
|
done;
|
||||||
|
for t = 16 to 79 do
|
||||||
|
let s0 =
|
||||||
|
(rotr64 w.(t - 15) 1) ^: (rotr64 w.(t - 15) 8)
|
||||||
|
^: (Int64.shift_right_logical w.(t - 15) 7) in
|
||||||
|
let s1 =
|
||||||
|
(rotr64 w.(t - 2) 19) ^: (rotr64 w.(t - 2) 61)
|
||||||
|
^: (Int64.shift_right_logical w.(t - 2) 6) in
|
||||||
|
w.(t) <- w.(t - 16) +: s0 +: w.(t - 7) +: s1
|
||||||
|
done;
|
||||||
|
let a = ref h.(0) and bb = ref h.(1) and c = ref h.(2)
|
||||||
|
and d = ref h.(3) and e = ref h.(4) and f = ref h.(5)
|
||||||
|
and g = ref h.(6) and hh = ref h.(7) in
|
||||||
|
for t = 0 to 79 do
|
||||||
|
let s1 = (rotr64 !e 14) ^: (rotr64 !e 18) ^: (rotr64 !e 41) in
|
||||||
|
let ch = (!e &: !f) ^: ((lnot64 !e) &: !g) in
|
||||||
|
let t1 = !hh +: s1 +: ch +: k512.(t) +: w.(t) in
|
||||||
|
let s0 = (rotr64 !a 28) ^: (rotr64 !a 34) ^: (rotr64 !a 39) in
|
||||||
|
let maj = (!a &: !bb) ^: (!a &: !c) ^: (!bb &: !c) in
|
||||||
|
let t2 = s0 +: maj in
|
||||||
|
hh := !g; g := !f; f := !e;
|
||||||
|
e := !d +: t1;
|
||||||
|
d := !c; c := !bb; bb := !a;
|
||||||
|
a := t1 +: t2
|
||||||
|
done;
|
||||||
|
h.(0) <- h.(0) +: !a;
|
||||||
|
h.(1) <- h.(1) +: !bb;
|
||||||
|
h.(2) <- h.(2) +: !c;
|
||||||
|
h.(3) <- h.(3) +: !d;
|
||||||
|
h.(4) <- h.(4) +: !e;
|
||||||
|
h.(5) <- h.(5) +: !f;
|
||||||
|
h.(6) <- h.(6) +: !g;
|
||||||
|
h.(7) <- h.(7) +: !hh
|
||||||
|
done;
|
||||||
|
let out = Buffer.create 128 in
|
||||||
|
Array.iter
|
||||||
|
(fun x -> Buffer.add_string out (Printf.sprintf "%016Lx" x)) h;
|
||||||
|
Buffer.contents out
|
||||||
107
hosts/ocaml/lib/sx_sha3.ml
Normal file
107
hosts/ocaml/lib/sx_sha3.ml
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
(** SHA-3 (SHA3-256) — pure OCaml, WASM-safe.
|
||||||
|
|
||||||
|
Keccak-f[1600] permutation + SHA-3 multi-rate padding (domain byte
|
||||||
|
0x06, NOT the legacy Keccak 0x01). Reference: FIPS 202. No deps. *)
|
||||||
|
|
||||||
|
let ( ^: ) = Int64.logxor
|
||||||
|
let ( &: ) = Int64.logand
|
||||||
|
let lnot64 = Int64.lognot
|
||||||
|
|
||||||
|
let rotl64 x n =
|
||||||
|
if n = 0 then x
|
||||||
|
else
|
||||||
|
Int64.logor (Int64.shift_left x n) (Int64.shift_right_logical x (64 - n))
|
||||||
|
|
||||||
|
(* FIPS 202 Table 2 — ρ rotation offsets, indexed lane = x + 5*y. *)
|
||||||
|
let rho = [|
|
||||||
|
0; 1; 62; 28; 27;
|
||||||
|
36; 44; 6; 55; 20;
|
||||||
|
3; 10; 43; 25; 39;
|
||||||
|
41; 45; 15; 21; 8;
|
||||||
|
18; 2; 61; 56; 14 |]
|
||||||
|
|
||||||
|
(* FIPS 202 §3.2.5 — round constants RC[0..23] for ι. *)
|
||||||
|
let rc = [|
|
||||||
|
0x0000000000000001L; 0x0000000000008082L; 0x800000000000808aL;
|
||||||
|
0x8000000080008000L; 0x000000000000808bL; 0x0000000080000001L;
|
||||||
|
0x8000000080008081L; 0x8000000000008009L; 0x000000000000008aL;
|
||||||
|
0x0000000000000088L; 0x0000000080008009L; 0x000000008000000aL;
|
||||||
|
0x000000008000808bL; 0x800000000000008bL; 0x8000000000008089L;
|
||||||
|
0x8000000000008003L; 0x8000000000008002L; 0x8000000000000080L;
|
||||||
|
0x000000000000800aL; 0x800000008000000aL; 0x8000000080008081L;
|
||||||
|
0x8000000000008080L; 0x0000000080000001L; 0x8000000080008008L |]
|
||||||
|
|
||||||
|
let keccak_f (a : int64 array) : unit =
|
||||||
|
let c = Array.make 5 0L and d = Array.make 5 0L in
|
||||||
|
let b = Array.make 25 0L in
|
||||||
|
for round = 0 to 23 do
|
||||||
|
(* θ *)
|
||||||
|
for x = 0 to 4 do
|
||||||
|
c.(x) <- a.(x) ^: a.(x + 5) ^: a.(x + 10)
|
||||||
|
^: a.(x + 15) ^: a.(x + 20)
|
||||||
|
done;
|
||||||
|
for x = 0 to 4 do
|
||||||
|
d.(x) <- c.((x + 4) mod 5) ^: (rotl64 c.((x + 1) mod 5) 1)
|
||||||
|
done;
|
||||||
|
for x = 0 to 4 do
|
||||||
|
for y = 0 to 4 do
|
||||||
|
a.(x + 5 * y) <- a.(x + 5 * y) ^: d.(x)
|
||||||
|
done
|
||||||
|
done;
|
||||||
|
(* ρ and π: B[y, 2x+3y] = rotl(A[x,y], rho[x,y]) *)
|
||||||
|
for x = 0 to 4 do
|
||||||
|
for y = 0 to 4 do
|
||||||
|
let nx = y and ny = (2 * x + 3 * y) mod 5 in
|
||||||
|
b.(nx + 5 * ny) <- rotl64 a.(x + 5 * y) rho.(x + 5 * y)
|
||||||
|
done
|
||||||
|
done;
|
||||||
|
(* χ *)
|
||||||
|
for y = 0 to 4 do
|
||||||
|
for x = 0 to 4 do
|
||||||
|
a.(x + 5 * y) <-
|
||||||
|
b.(x + 5 * y)
|
||||||
|
^: ((lnot64 b.((x + 1) mod 5 + 5 * y))
|
||||||
|
&: b.((x + 2) mod 5 + 5 * y))
|
||||||
|
done
|
||||||
|
done;
|
||||||
|
(* ι *)
|
||||||
|
a.(0) <- a.(0) ^: rc.(round)
|
||||||
|
done
|
||||||
|
|
||||||
|
let sha3_256_hex (msg : string) : string =
|
||||||
|
let rate = 136 (* bytes: (1600 - 2*256) / 8 *) in
|
||||||
|
let len = String.length msg in
|
||||||
|
(* pad10*1 with SHA-3 domain byte 0x06; last byte ORed with 0x80. *)
|
||||||
|
let q = rate - (len mod rate) in
|
||||||
|
let padded = Bytes.make (len + q) '\000' in
|
||||||
|
Bytes.blit_string msg 0 padded 0 len;
|
||||||
|
if q = 1 then
|
||||||
|
Bytes.set padded len '\x86'
|
||||||
|
else begin
|
||||||
|
Bytes.set padded len '\x06';
|
||||||
|
Bytes.set padded (len + q - 1) '\x80'
|
||||||
|
end;
|
||||||
|
let total = Bytes.length padded in
|
||||||
|
let a = Array.make 25 0L in
|
||||||
|
let nblocks = total / rate in
|
||||||
|
for blk = 0 to nblocks - 1 do
|
||||||
|
let base = blk * rate in
|
||||||
|
(* Absorb: XOR rate bytes into the state, little-endian lanes. *)
|
||||||
|
for j = 0 to rate - 1 do
|
||||||
|
let lane = j / 8 and sh = (j mod 8) * 8 in
|
||||||
|
let byte = Int64.of_int (Char.code (Bytes.get padded (base + j))) in
|
||||||
|
a.(lane) <- a.(lane) ^: (Int64.shift_left byte sh)
|
||||||
|
done;
|
||||||
|
keccak_f a
|
||||||
|
done;
|
||||||
|
(* Squeeze 32 bytes (fits in the first 4 lanes; rate > 32). *)
|
||||||
|
let out = Buffer.create 64 in
|
||||||
|
for j = 0 to 31 do
|
||||||
|
let lane = j / 8 and sh = (j mod 8) * 8 in
|
||||||
|
let byte =
|
||||||
|
Int64.to_int
|
||||||
|
(Int64.logand (Int64.shift_right_logical a.(lane) sh) 0xFFL)
|
||||||
|
in
|
||||||
|
Buffer.add_string out (Printf.sprintf "%02x" byte)
|
||||||
|
done;
|
||||||
|
Buffer.contents out
|
||||||
@@ -44,6 +44,11 @@ type vm = {
|
|||||||
ip past OP_PERFORM, stack ready for a result push). *)
|
ip past OP_PERFORM, stack ready for a result push). *)
|
||||||
exception VmSuspended of value * vm
|
exception VmSuspended of value * vm
|
||||||
|
|
||||||
|
(** Raised by the extension dispatch fallthrough when an opcode in the
|
||||||
|
extension range (≥ 200) is encountered with no handler registered.
|
||||||
|
Carries the offending opcode id. See plans/sx-vm-opcode-extension.md. *)
|
||||||
|
exception Invalid_opcode of int
|
||||||
|
|
||||||
(* Register the VM suspension converter so sx_runtime.sx_apply_cek can
|
(* Register the VM suspension converter so sx_runtime.sx_apply_cek can
|
||||||
catch VmSuspended and convert it to CekPerformRequest without a
|
catch VmSuspended and convert it to CekPerformRequest without a
|
||||||
direct dependency on this module. *)
|
direct dependency on this module. *)
|
||||||
@@ -57,6 +62,21 @@ let () = Sx_types._convert_vm_suspension := (fun exn ->
|
|||||||
let jit_compile_ref : (lambda -> (string, value) Hashtbl.t -> vm_closure option) ref =
|
let jit_compile_ref : (lambda -> (string, value) Hashtbl.t -> vm_closure option) ref =
|
||||||
ref (fun _ _ -> None)
|
ref (fun _ _ -> None)
|
||||||
|
|
||||||
|
(** Forward reference for extension opcode dispatch — Phase B installs the
|
||||||
|
real registry's dispatch function here at module init. Until then, any
|
||||||
|
opcode in the extension range raises [Invalid_opcode]. Same forward-ref
|
||||||
|
pattern as [jit_compile_ref] above; keeps [Sx_vm_extensions] free to
|
||||||
|
depend on [Sx_vm]'s [vm] / [frame] types without a cycle. *)
|
||||||
|
let extension_dispatch_ref : (int -> vm -> frame -> unit) ref =
|
||||||
|
ref (fun op _vm _frame -> raise (Invalid_opcode op))
|
||||||
|
|
||||||
|
(** Forward reference for extension opcode → name lookup, used by
|
||||||
|
[opcode_name] / [disassemble] for human-readable disassembly. The
|
||||||
|
registry installs a real lookup at module init; default returns
|
||||||
|
[None] (then [opcode_name] falls back to "UNKNOWN_n"). *)
|
||||||
|
let extension_opcode_name_ref : (int -> string option) ref =
|
||||||
|
ref (fun _ -> None)
|
||||||
|
|
||||||
(* JIT threshold and counters live in Sx_types so primitives can read them
|
(* JIT threshold and counters live in Sx_types so primitives can read them
|
||||||
without creating a sx_primitives → sx_vm dependency cycle. *)
|
without creating a sx_primitives → sx_vm dependency cycle. *)
|
||||||
|
|
||||||
@@ -875,6 +895,15 @@ and run vm =
|
|||||||
let request = pop vm in
|
let request = pop vm in
|
||||||
raise (VmSuspended (request, vm))
|
raise (VmSuspended (request, vm))
|
||||||
|
|
||||||
|
(* ---- Extension dispatch fallthrough ----
|
||||||
|
Opcode partition (see plans/sx-vm-opcode-extension.md):
|
||||||
|
0 reserved / NOP
|
||||||
|
1-199 core opcodes (current ceiling 175 = OP_DEC)
|
||||||
|
200-247 extension opcodes (registered via Sx_vm_extensions)
|
||||||
|
248-255 reserved for future expansion / multi-byte
|
||||||
|
Any opcode ≥ 200 routes through the extension registry. *)
|
||||||
|
| op when op >= 200 -> !extension_dispatch_ref op vm frame
|
||||||
|
|
||||||
| opcode ->
|
| opcode ->
|
||||||
raise (Eval_error (Printf.sprintf "VM: unknown opcode %d at ip=%d"
|
raise (Eval_error (Printf.sprintf "VM: unknown opcode %d at ip=%d"
|
||||||
opcode (frame.ip - 1)))
|
opcode (frame.ip - 1)))
|
||||||
@@ -1027,6 +1056,62 @@ let _jit_is_broken_name n =
|
|||||||
|| n = "hs-repeat-while" || n = "hs-repeat-until"
|
|| n = "hs-repeat-while" || n = "hs-repeat-until"
|
||||||
|| n = "hs-for-each" || n = "hs-put!"
|
|| n = "hs-for-each" || n = "hs-put!"
|
||||||
|
|
||||||
|
(** Scan bytecode for any extension opcode (≥ 200, the registry's
|
||||||
|
[Sx_vm_extensions.extension_min]). Walks operand bytes correctly
|
||||||
|
so values that happen to be ≥200 (e.g. a CONST u16 index pointing
|
||||||
|
into a large pool) do not trigger false positives. CLOSURE's
|
||||||
|
dynamic upvalue descriptors are read from the constant pool entry
|
||||||
|
at the same index it pushes.
|
||||||
|
|
||||||
|
Used by [jit_compile_lambda] (Phase E of the opcode-extension
|
||||||
|
plan): a lambda whose compiled body contains any extension opcode
|
||||||
|
is routed through interpretation rather than JIT. Extensions
|
||||||
|
interpret their opcodes via the registry; the JIT does not
|
||||||
|
currently know how to compile them.
|
||||||
|
|
||||||
|
Operand-size logic mirrors [opcode_operand_size] (which is defined
|
||||||
|
later, in the disassembly section); inlined here so this helper can
|
||||||
|
sit before [jit_compile_lambda] in the file. *)
|
||||||
|
let bytecode_uses_extension_opcodes (bc : int array) (consts : value array) =
|
||||||
|
let core_operand_size = function
|
||||||
|
| 1 | 20 | 21 | 64 | 65 | 128 -> 2 (* u16 *)
|
||||||
|
| 16 | 17 | 18 | 19 | 48 | 49 | 144 -> 1 (* u8 *)
|
||||||
|
| 32 | 33 | 34 | 35 -> 2 (* i16 *)
|
||||||
|
| 52 -> 3 (* CALL_PRIM: u16 + u8 *)
|
||||||
|
| _ -> 0
|
||||||
|
in
|
||||||
|
let len = Array.length bc in
|
||||||
|
let ip = ref 0 in
|
||||||
|
let found = ref false in
|
||||||
|
while not !found && !ip < len do
|
||||||
|
let op = bc.(!ip) in
|
||||||
|
if op >= 200 then found := true
|
||||||
|
else begin
|
||||||
|
ip := !ip + 1;
|
||||||
|
let extra = match op with
|
||||||
|
| 51 (* CLOSURE *) when !ip + 1 < len ->
|
||||||
|
let lo = bc.(!ip) in
|
||||||
|
let hi = bc.(!ip + 1) in
|
||||||
|
let idx = lo lor (hi lsl 8) in
|
||||||
|
let uv_count =
|
||||||
|
if idx < Array.length consts then
|
||||||
|
(match consts.(idx) with
|
||||||
|
| Dict d ->
|
||||||
|
(match Hashtbl.find_opt d "upvalue-count" with
|
||||||
|
| Some (Integer n) -> n
|
||||||
|
| Some (Number n) -> int_of_float n
|
||||||
|
| _ -> 0)
|
||||||
|
| _ -> 0)
|
||||||
|
else 0
|
||||||
|
in
|
||||||
|
2 + uv_count * 2
|
||||||
|
| _ -> core_operand_size op
|
||||||
|
in
|
||||||
|
ip := !ip + extra
|
||||||
|
end
|
||||||
|
done;
|
||||||
|
!found
|
||||||
|
|
||||||
let jit_compile_lambda (l : lambda) globals =
|
let jit_compile_lambda (l : lambda) globals =
|
||||||
let fn_name = match l.l_name with Some n -> n | None -> "<anon>" in
|
let fn_name = match l.l_name with Some n -> n | None -> "<anon>" in
|
||||||
if !_jit_compiling then (
|
if !_jit_compiling then (
|
||||||
@@ -1089,8 +1174,18 @@ let jit_compile_lambda (l : lambda) globals =
|
|||||||
if idx < Array.length outer_code.vc_constants then
|
if idx < Array.length outer_code.vc_constants then
|
||||||
let inner_val = outer_code.vc_constants.(idx) in
|
let inner_val = outer_code.vc_constants.(idx) in
|
||||||
let code = code_from_value inner_val in
|
let code = code_from_value inner_val in
|
||||||
Some { vm_code = code; vm_upvalues = [||];
|
(* Phase E: if the inner lambda's bytecode contains any
|
||||||
vm_name = l.l_name; vm_env_ref = effective_globals; vm_closure_env = Some l.l_closure }
|
extension opcode (≥200), skip JIT and let the lambda run
|
||||||
|
interpreted via CEK. Extension opcodes dispatch correctly
|
||||||
|
through the VM's registry fallthrough, but the JIT has no
|
||||||
|
knowledge of them and shouldn't claim ownership. *)
|
||||||
|
if bytecode_uses_extension_opcodes code.vc_bytecode code.vc_constants then begin
|
||||||
|
Printf.eprintf "[jit] SKIP %s: bytecode uses extension opcodes (interpret-only in v1)\n%!"
|
||||||
|
fn_name;
|
||||||
|
None
|
||||||
|
end else
|
||||||
|
Some { vm_code = code; vm_upvalues = [||];
|
||||||
|
vm_name = l.l_name; vm_env_ref = effective_globals; vm_closure_env = Some l.l_closure }
|
||||||
else begin
|
else begin
|
||||||
Printf.eprintf "[jit] FAIL %s: closure index %d out of bounds (pool=%d)\n%!"
|
Printf.eprintf "[jit] FAIL %s: closure index %d out of bounds (pool=%d)\n%!"
|
||||||
fn_name idx (Array.length outer_code.vc_constants);
|
fn_name idx (Array.length outer_code.vc_constants);
|
||||||
@@ -1200,7 +1295,12 @@ let opcode_name = function
|
|||||||
| 164 -> "EQ" | 165 -> "LT" | 166 -> "GT" | 167 -> "NOT"
|
| 164 -> "EQ" | 165 -> "LT" | 166 -> "GT" | 167 -> "NOT"
|
||||||
| 168 -> "LEN" | 169 -> "FIRST" | 170 -> "REST" | 171 -> "NTH"
|
| 168 -> "LEN" | 169 -> "FIRST" | 170 -> "REST" | 171 -> "NTH"
|
||||||
| 172 -> "CONS" | 173 -> "NEG" | 174 -> "INC" | 175 -> "DEC"
|
| 172 -> "CONS" | 173 -> "NEG" | 174 -> "INC" | 175 -> "DEC"
|
||||||
| n -> Printf.sprintf "UNKNOWN_%d" n
|
| n ->
|
||||||
|
(* Extension opcodes (≥200) get their human-readable name from the
|
||||||
|
registry; defaults to UNKNOWN_n if the extension isn't loaded. *)
|
||||||
|
(match !extension_opcode_name_ref n with
|
||||||
|
| Some name -> name
|
||||||
|
| None -> Printf.sprintf "UNKNOWN_%d" n)
|
||||||
|
|
||||||
(** Number of extra operand bytes consumed by each opcode.
|
(** Number of extra operand bytes consumed by each opcode.
|
||||||
Returns (format, total_bytes) where format describes the operand types. *)
|
Returns (format, total_bytes) where format describes the operand types. *)
|
||||||
|
|||||||
48
hosts/ocaml/lib/sx_vm_extension.ml
Normal file
48
hosts/ocaml/lib/sx_vm_extension.ml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
(** {1 VM extension interface}
|
||||||
|
|
||||||
|
Type definitions for VM bytecode extensions. See
|
||||||
|
[plans/sx-vm-opcode-extension.md].
|
||||||
|
|
||||||
|
An extension is a first-class module of type [EXTENSION]: it has a
|
||||||
|
stable [name], an [init] that returns its private state, and an
|
||||||
|
[opcodes] function that lists the opcodes it provides.
|
||||||
|
|
||||||
|
Opcode handlers receive the live [vm] and the active [frame]. They
|
||||||
|
read operands via [Sx_vm.read_u8] / [read_u16], manipulate the stack
|
||||||
|
via [push] / [pop] / [peek], and update the frame's [ip] as needed. *)
|
||||||
|
|
||||||
|
(** A handler for an extension opcode. Reads operands from bytecode,
|
||||||
|
manipulates the VM stack, updates the frame's instruction pointer.
|
||||||
|
May raise exceptions (which propagate via the existing VM error path). *)
|
||||||
|
type handler = Sx_vm.vm -> Sx_vm.frame -> unit
|
||||||
|
|
||||||
|
(** State an extension carries alongside the VM. Opaque to the VM core;
|
||||||
|
extensions extend this with their own constructor and cast as needed.
|
||||||
|
|
||||||
|
Extensible variant — extensions add cases:
|
||||||
|
{[
|
||||||
|
type Sx_vm_extension.extension_state +=
|
||||||
|
| ErlangState of erlang_scheduler
|
||||||
|
]} *)
|
||||||
|
type extension_state = ..
|
||||||
|
|
||||||
|
(** An extension is a first-class module of this signature. *)
|
||||||
|
module type EXTENSION = sig
|
||||||
|
(** Stable name for this extension (e.g. ["erlang"], ["guest_vm"]).
|
||||||
|
Used as the lookup key in the registry and as the prefix for opcode
|
||||||
|
names ([erlang.OP_PATTERN_TUPLE_2] etc). *)
|
||||||
|
val name : string
|
||||||
|
|
||||||
|
(** Initialize per-instance state. Called once when [register] is
|
||||||
|
invoked on this extension. *)
|
||||||
|
val init : unit -> extension_state
|
||||||
|
|
||||||
|
(** Opcodes this extension provides. Each is
|
||||||
|
[(opcode_id, opcode_name, handler)].
|
||||||
|
|
||||||
|
[opcode_id] must be in the range 200-247 (the extension partition;
|
||||||
|
see the partition comment at the top of [Sx_vm]'s dispatch loop).
|
||||||
|
Conflicts with already-registered opcodes cause [register] to
|
||||||
|
fail. *)
|
||||||
|
val opcodes : extension_state -> (int * string * handler) list
|
||||||
|
end
|
||||||
120
hosts/ocaml/lib/sx_vm_extensions.ml
Normal file
120
hosts/ocaml/lib/sx_vm_extensions.ml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
(** {1 VM extension registry}
|
||||||
|
|
||||||
|
Holds the live registry of extension opcodes and installs the
|
||||||
|
[dispatch] function into [Sx_vm.extension_dispatch_ref] at module
|
||||||
|
init time, replacing Phase A's stub.
|
||||||
|
|
||||||
|
See [plans/sx-vm-opcode-extension.md] and [Sx_vm_extension] for the
|
||||||
|
extension interface. *)
|
||||||
|
|
||||||
|
open Sx_vm_extension
|
||||||
|
|
||||||
|
(** The opcode range an extension is allowed to claim.
|
||||||
|
Mirrors the partition comment in [Sx_vm]. *)
|
||||||
|
let extension_min = 200
|
||||||
|
let extension_max = 247
|
||||||
|
|
||||||
|
(** opcode_id → handler *)
|
||||||
|
let by_id : (int, handler) Hashtbl.t = Hashtbl.create 64
|
||||||
|
|
||||||
|
(** opcode_name → opcode_id *)
|
||||||
|
let by_name : (string, int) Hashtbl.t = Hashtbl.create 64
|
||||||
|
|
||||||
|
(** opcode_id → opcode_name (reverse of [by_name]; used by
|
||||||
|
[Sx_vm.opcode_name] for disassembly). *)
|
||||||
|
let name_of_id_table : (int, string) Hashtbl.t = Hashtbl.create 64
|
||||||
|
|
||||||
|
(** extension_name → state *)
|
||||||
|
let states : (string, extension_state) Hashtbl.t = Hashtbl.create 8
|
||||||
|
|
||||||
|
(** Registered extension names, newest first. *)
|
||||||
|
let extensions : string list ref = ref []
|
||||||
|
|
||||||
|
(** Dispatch an extension opcode to its registered handler. Raises
|
||||||
|
[Sx_vm.Invalid_opcode] if no handler is registered for [op]. *)
|
||||||
|
let dispatch op vm frame =
|
||||||
|
match Hashtbl.find_opt by_id op with
|
||||||
|
| Some handler -> handler vm frame
|
||||||
|
| None -> raise (Sx_vm.Invalid_opcode op)
|
||||||
|
|
||||||
|
(** Register an extension. Fails if the extension name is already
|
||||||
|
registered, or if any opcode_id is outside the extension range or
|
||||||
|
collides with an already-registered opcode. *)
|
||||||
|
let register (m : (module EXTENSION)) =
|
||||||
|
let module M = (val m) in
|
||||||
|
if Hashtbl.mem states M.name then
|
||||||
|
failwith (Printf.sprintf
|
||||||
|
"Sx_vm_extensions: extension %S already registered" M.name);
|
||||||
|
let st = M.init () in
|
||||||
|
let ops = M.opcodes st in
|
||||||
|
List.iter (fun (id, opname, _h) ->
|
||||||
|
if id < extension_min || id > extension_max then
|
||||||
|
failwith (Printf.sprintf
|
||||||
|
"Sx_vm_extensions: opcode %d (%s) outside extension range %d-%d"
|
||||||
|
id opname extension_min extension_max);
|
||||||
|
if Hashtbl.mem by_id id then
|
||||||
|
failwith (Printf.sprintf
|
||||||
|
"Sx_vm_extensions: opcode %d (%s) already registered" id opname);
|
||||||
|
if Hashtbl.mem by_name opname then
|
||||||
|
failwith (Printf.sprintf
|
||||||
|
"Sx_vm_extensions: opcode name %S already registered" opname)
|
||||||
|
) ops;
|
||||||
|
Hashtbl.add states M.name st;
|
||||||
|
List.iter (fun (id, opname, h) ->
|
||||||
|
Hashtbl.add by_id id h;
|
||||||
|
Hashtbl.add by_name opname id;
|
||||||
|
Hashtbl.add name_of_id_table id opname
|
||||||
|
) ops;
|
||||||
|
extensions := M.name :: !extensions
|
||||||
|
|
||||||
|
(** Look up the opcode_id for an opcode_name. Returns [None] if no
|
||||||
|
extension provides that opcode. *)
|
||||||
|
let id_of_name name = Hashtbl.find_opt by_name name
|
||||||
|
|
||||||
|
(** Look up the opcode_name for an opcode_id. Returns [None] if no
|
||||||
|
extension provides that opcode. Used by disassembly. *)
|
||||||
|
let name_of_id id = Hashtbl.find_opt name_of_id_table id
|
||||||
|
|
||||||
|
(** Look up the state of an extension by name. Returns [None] if the
|
||||||
|
extension is not registered. *)
|
||||||
|
let state_of_extension name = Hashtbl.find_opt states name
|
||||||
|
|
||||||
|
(** Names of all registered extensions, newest first. *)
|
||||||
|
let registered_extensions () = !extensions
|
||||||
|
|
||||||
|
(** Test-only: clear the registry. Used by unit tests to isolate
|
||||||
|
extensions between test cases. The dispatch_ref is left in place. *)
|
||||||
|
let _reset_for_tests () =
|
||||||
|
Hashtbl.clear by_id;
|
||||||
|
Hashtbl.clear by_name;
|
||||||
|
Hashtbl.clear name_of_id_table;
|
||||||
|
Hashtbl.clear states;
|
||||||
|
extensions := []
|
||||||
|
|
||||||
|
(** Install our [dispatch] into [Sx_vm.extension_dispatch_ref] and our
|
||||||
|
[name_of_id] into [Sx_vm.extension_opcode_name_ref], replacing
|
||||||
|
the Phase A stubs. Idempotent. Called automatically at module init. *)
|
||||||
|
let install_dispatch () =
|
||||||
|
Sx_vm.extension_dispatch_ref := dispatch;
|
||||||
|
Sx_vm.extension_opcode_name_ref := name_of_id
|
||||||
|
|
||||||
|
let () = install_dispatch ()
|
||||||
|
|
||||||
|
(** Compiler-side opcode lookup: register the [extension-opcode-id]
|
||||||
|
primitive. Compilers ([lib/compiler.sx]) call this to emit
|
||||||
|
extension opcodes by name. Returns [Integer id] when registered,
|
||||||
|
[Nil] otherwise — so missing extensions degrade to a fallback
|
||||||
|
rather than failure. *)
|
||||||
|
let () =
|
||||||
|
Sx_primitives.register "extension-opcode-id" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [Sx_types.String name] ->
|
||||||
|
(match id_of_name name with
|
||||||
|
| Some id -> Sx_types.Integer id
|
||||||
|
| None -> Sx_types.Nil)
|
||||||
|
| [Sx_types.Symbol name] ->
|
||||||
|
(match id_of_name name with
|
||||||
|
| Some id -> Sx_types.Integer id
|
||||||
|
| None -> Sx_types.Nil)
|
||||||
|
| _ -> raise (Sx_types.Eval_error
|
||||||
|
"extension-opcode-id: expected one string or symbol"))
|
||||||
@@ -16,5 +16,5 @@
|
|||||||
{"name":"magic","passed":37,"failed":0,"total":37},
|
{"name":"magic","passed":37,"failed":0,"total":37},
|
||||||
{"name":"demo","passed":21,"failed":0,"total":21}
|
{"name":"demo","passed":21,"failed":0,"total":21}
|
||||||
],
|
],
|
||||||
"generated": "2026-05-11T09:40:12+00:00"
|
"generated": "2026-05-14T20:30:05+00:00"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,3 +33,54 @@ least: persistent (path-copying) envs, an inline scheduler that
|
|||||||
doesn't call/cc on the common path (msg-already-in-mailbox), and a
|
doesn't call/cc on the common path (msg-already-in-mailbox), and a
|
||||||
linked-list mailbox. None of those are in scope for the Phase 3
|
linked-list mailbox. None of those are in scope for the Phase 3
|
||||||
checkbox — captured here as the floor we're starting from.
|
checkbox — captured here as the floor we're starting from.
|
||||||
|
|
||||||
|
## Phase 9 status (2026-05-14)
|
||||||
|
|
||||||
|
Specialized opcodes 9b–9f landed as **stub dispatchers** in
|
||||||
|
`lib/erlang/vm/dispatcher.sx`: `OP_PATTERN_TUPLE/LIST/BINARY`,
|
||||||
|
`OP_PERFORM/HANDLE`, `OP_RECEIVE_SCAN`, `OP_SPAWN/SEND`, and ten
|
||||||
|
`OP_BIF_*` hot dispatch entries. Each opcode's handler is a thin
|
||||||
|
wrapper over the existing `er-match-*` / `er-bif-*` / runtime impls,
|
||||||
|
so **the perf numbers above are unchanged** — same per-hop cost, same
|
||||||
|
scheduler. The stubs exist to nail down opcode IDs, operand contracts,
|
||||||
|
and tests against `er-match!` parity *before* 9a (the OCaml
|
||||||
|
opcode-extension mechanism in `hosts/ocaml/evaluator/`) lands.
|
||||||
|
|
||||||
|
When 9a integrates and the bytecode compiler can emit these opcodes
|
||||||
|
at hot call sites, the real speedup story (~3000× ring throughput,
|
||||||
|
~1000× spawn) starts. Until then this file documents the
|
||||||
|
pre-integration ceiling. 72 vm-suite tests guard the stub correctness;
|
||||||
|
full conformance is **709/709** with the stub infrastructure loaded.
|
||||||
|
|
||||||
|
## Phase 9g — post-integration bench (2026-05-15)
|
||||||
|
|
||||||
|
9a (vm-ext mechanism), 9h (`erlang_ext.ml` registering `erlang.OP_*`
|
||||||
|
ids 222-239), and 9i (SX dispatcher consulting `extension-opcode-id`)
|
||||||
|
are now integrated and built into `hosts/ocaml/_build/default/bin/sx_server.exe`.
|
||||||
|
Re-ran the ring ladder on that binary:
|
||||||
|
|
||||||
|
| N (processes) | Hops | Wall-clock | Throughput |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 10 | 10 | 938ms | 11 hops/s |
|
||||||
|
| 100 | 100 | 2772ms | 36 hops/s |
|
||||||
|
| 500 | 500 | 14190ms | 35 hops/s |
|
||||||
|
| 1000 | 1000 | 31814ms | 31 hops/s |
|
||||||
|
|
||||||
|
**Numbers are unchanged from the pre-integration baseline** — and that
|
||||||
|
is the expected, correct result. The opcode handlers (both the SX stub
|
||||||
|
dispatcher and the OCaml `erlang_ext` module) wrap the existing
|
||||||
|
`er-match-*` / `er-bif-*` / scheduler implementations 1-to-1, and the
|
||||||
|
**bytecode compiler does not yet emit `erlang.OP_*` opcodes**, so every
|
||||||
|
hop still goes through the general CEK path exactly as before. The
|
||||||
|
unchanged numbers therefore double as a no-regression check: the full
|
||||||
|
extension wiring (cherry-picked vm-ext A-E + force-link + erlang_ext +
|
||||||
|
SX bridge) added zero per-hop cost. Conformance **715/715** on this
|
||||||
|
binary.
|
||||||
|
|
||||||
|
The ~3000×/~1000× targets remain gated on a **future phase (Phase 10 —
|
||||||
|
bytecode emission)**: teach `lib/compiler.sx` (or the Erlang
|
||||||
|
transpiler) to emit `erlang.OP_PATTERN_TUPLE` etc. at hot call sites,
|
||||||
|
then give `erlang_ext.ml` real register-machine handlers instead of the
|
||||||
|
current honest not-wired raise. That is a substantial standalone phase,
|
||||||
|
tracked in `plans/erlang-on-sx.md`. 9g's deliverable — *honest
|
||||||
|
measurement + recorded numbers on the integrated binary* — is complete.
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ SUITES=(
|
|||||||
"bank|er-bank-test-pass|er-bank-test-count"
|
"bank|er-bank-test-pass|er-bank-test-count"
|
||||||
"echo|er-echo-test-pass|er-echo-test-count"
|
"echo|er-echo-test-pass|er-echo-test-count"
|
||||||
"fib|er-fib-test-pass|er-fib-test-count"
|
"fib|er-fib-test-pass|er-fib-test-count"
|
||||||
|
"ffi|er-ffi-test-pass|er-ffi-test-count"
|
||||||
|
"vm|er-vm-test-pass|er-vm-test-count"
|
||||||
|
"send_after|er-sa-test-pass|er-sa-test-count"
|
||||||
)
|
)
|
||||||
|
|
||||||
cat > "$TMPFILE" << 'EPOCHS'
|
cat > "$TMPFILE" << 'EPOCHS'
|
||||||
@@ -56,6 +59,10 @@ cat > "$TMPFILE" << 'EPOCHS'
|
|||||||
(load "lib/erlang/tests/programs/bank.sx")
|
(load "lib/erlang/tests/programs/bank.sx")
|
||||||
(load "lib/erlang/tests/programs/echo.sx")
|
(load "lib/erlang/tests/programs/echo.sx")
|
||||||
(load "lib/erlang/tests/programs/fib_server.sx")
|
(load "lib/erlang/tests/programs/fib_server.sx")
|
||||||
|
(load "lib/erlang/vm/dispatcher.sx")
|
||||||
|
(load "lib/erlang/tests/ffi.sx")
|
||||||
|
(load "lib/erlang/tests/vm.sx")
|
||||||
|
(load "lib/erlang/tests/send_after.sx")
|
||||||
(epoch 100)
|
(epoch 100)
|
||||||
(eval "(list er-test-pass er-test-count)")
|
(eval "(list er-test-pass er-test-count)")
|
||||||
(epoch 101)
|
(epoch 101)
|
||||||
@@ -74,6 +81,12 @@ cat > "$TMPFILE" << 'EPOCHS'
|
|||||||
(eval "(list er-echo-test-pass er-echo-test-count)")
|
(eval "(list er-echo-test-pass er-echo-test-count)")
|
||||||
(epoch 108)
|
(epoch 108)
|
||||||
(eval "(list er-fib-test-pass er-fib-test-count)")
|
(eval "(list er-fib-test-pass er-fib-test-count)")
|
||||||
|
(epoch 109)
|
||||||
|
(eval "(list er-ffi-test-pass er-ffi-test-count)")
|
||||||
|
(epoch 110)
|
||||||
|
(eval "(list er-vm-test-pass er-vm-test-count)")
|
||||||
|
(epoch 111)
|
||||||
|
(eval "(list er-sa-test-pass er-sa-test-count)")
|
||||||
EPOCHS
|
EPOCHS
|
||||||
|
|
||||||
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
timeout 600 "$SX_SERVER" < "$TMPFILE" > "$OUTFILE" 2>&1
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,19 @@
|
|||||||
{
|
{
|
||||||
"language": "erlang",
|
"language": "erlang",
|
||||||
"total_pass": 530,
|
"total_pass": 771,
|
||||||
"total": 530,
|
"total": 771,
|
||||||
"suites": [
|
"suites": [
|
||||||
{"name":"tokenize","pass":62,"total":62,"status":"ok"},
|
{"name":"tokenize","pass":62,"total":62,"status":"ok"},
|
||||||
{"name":"parse","pass":52,"total":52,"status":"ok"},
|
{"name":"parse","pass":52,"total":52,"status":"ok"},
|
||||||
{"name":"eval","pass":346,"total":346,"status":"ok"},
|
{"name":"eval","pass":408,"total":408,"status":"ok"},
|
||||||
{"name":"runtime","pass":39,"total":39,"status":"ok"},
|
{"name":"runtime","pass":93,"total":93,"status":"ok"},
|
||||||
{"name":"ring","pass":4,"total":4,"status":"ok"},
|
{"name":"ring","pass":4,"total":4,"status":"ok"},
|
||||||
{"name":"ping-pong","pass":4,"total":4,"status":"ok"},
|
{"name":"ping-pong","pass":4,"total":4,"status":"ok"},
|
||||||
{"name":"bank","pass":8,"total":8,"status":"ok"},
|
{"name":"bank","pass":8,"total":8,"status":"ok"},
|
||||||
{"name":"echo","pass":7,"total":7,"status":"ok"},
|
{"name":"echo","pass":7,"total":7,"status":"ok"},
|
||||||
{"name":"fib","pass":8,"total":8,"status":"ok"}
|
{"name":"fib","pass":8,"total":8,"status":"ok"},
|
||||||
|
{"name":"ffi","pass":37,"total":37,"status":"ok"},
|
||||||
|
{"name":"vm","pass":78,"total":78,"status":"ok"},
|
||||||
|
{"name":"send_after","pass":10,"total":10,"status":"ok"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
# Erlang-on-SX Scoreboard
|
# Erlang-on-SX Scoreboard
|
||||||
|
|
||||||
**Total: 530 / 530 tests passing**
|
**Total: 771 / 771 tests passing**
|
||||||
|
|
||||||
| | Suite | Pass | Total |
|
| | Suite | Pass | Total |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| ✅ | tokenize | 62 | 62 |
|
| ✅ | tokenize | 62 | 62 |
|
||||||
| ✅ | parse | 52 | 52 |
|
| ✅ | parse | 52 | 52 |
|
||||||
| ✅ | eval | 346 | 346 |
|
| ✅ | eval | 408 | 408 |
|
||||||
| ✅ | runtime | 39 | 39 |
|
| ✅ | runtime | 93 | 93 |
|
||||||
| ✅ | ring | 4 | 4 |
|
| ✅ | ring | 4 | 4 |
|
||||||
| ✅ | ping-pong | 4 | 4 |
|
| ✅ | ping-pong | 4 | 4 |
|
||||||
| ✅ | bank | 8 | 8 |
|
| ✅ | bank | 8 | 8 |
|
||||||
| ✅ | echo | 7 | 7 |
|
| ✅ | echo | 7 | 7 |
|
||||||
| ✅ | fib | 8 | 8 |
|
| ✅ | fib | 8 | 8 |
|
||||||
|
| ✅ | ffi | 37 | 37 |
|
||||||
|
| ✅ | vm | 78 | 78 |
|
||||||
|
| ✅ | send_after | 10 | 10 |
|
||||||
|
|
||||||
|
|
||||||
Generated by `lib/erlang/conformance.sh`.
|
Generated by `lib/erlang/conformance.sh`.
|
||||||
|
|||||||
@@ -228,9 +228,10 @@
|
|||||||
(er-eval-test "tuple_size 0" (ev "tuple_size({})") 0)
|
(er-eval-test "tuple_size 0" (ev "tuple_size({})") 0)
|
||||||
|
|
||||||
;; ── BIFs: atom / list conversions ───────────────────────────────
|
;; ── BIFs: atom / list conversions ───────────────────────────────
|
||||||
(er-eval-test "atom_to_list" (ev "atom_to_list(hello)") "hello")
|
(er-eval-test "atom_to_list -> charlist length" (ev "length(atom_to_list(hello))") 5)
|
||||||
|
(er-eval-test "atom_to_list -> head $h" (ev "hd(atom_to_list(hello))") 104)
|
||||||
(er-eval-test "list_to_atom roundtrip"
|
(er-eval-test "list_to_atom roundtrip"
|
||||||
(nm (ev "list_to_atom(atom_to_list(foo))")) "foo")
|
(nm (ev "list_to_atom(atom_to_list(foo))")) "foo") ;; round-trip via charlist
|
||||||
(er-eval-test "list_to_atom fresh"
|
(er-eval-test "list_to_atom fresh"
|
||||||
(nm (ev "list_to_atom(\"bar\")")) "bar")
|
(nm (ev "list_to_atom(\"bar\")")) "bar")
|
||||||
|
|
||||||
@@ -1060,11 +1061,13 @@
|
|||||||
(er-eval-test "list_to_tuple roundtrip"
|
(er-eval-test "list_to_tuple roundtrip"
|
||||||
(ev "tuple_size(list_to_tuple([10, 20, 30]))") 3)
|
(ev "tuple_size(list_to_tuple([10, 20, 30]))") 3)
|
||||||
|
|
||||||
(er-eval-test "integer_to_list" (ev "integer_to_list(42)") "42")
|
(er-eval-test "integer_to_list -> charlist length" (ev "length(integer_to_list(42))") 2)
|
||||||
(er-eval-test "integer_to_list neg" (ev "integer_to_list(-99)") "-99")
|
(er-eval-test "integer_to_list 42 head $4" (ev "hd(integer_to_list(42))") 52)
|
||||||
|
(er-eval-test "integer_to_list neg -> charlist length" (ev "length(integer_to_list(-99))") 3)
|
||||||
|
(er-eval-test "integer_to_list -99 head $-" (ev "hd(integer_to_list(-99))") 45)
|
||||||
(er-eval-test "list_to_integer" (ev "list_to_integer(\"123\")") 123)
|
(er-eval-test "list_to_integer" (ev "list_to_integer(\"123\")") 123)
|
||||||
(er-eval-test "list_to_integer roundtrip"
|
(er-eval-test "list_to_integer roundtrip"
|
||||||
(ev "list_to_integer(integer_to_list(7))") 7)
|
(ev "list_to_integer(integer_to_list(7))") 7) ;; round-trip via charlist
|
||||||
|
|
||||||
(er-eval-test "is_function fun"
|
(er-eval-test "is_function fun"
|
||||||
(nm (ev "F = fun (X) -> X end, is_function(F)")) "true")
|
(nm (ev "F = fun (X) -> X end, is_function(F)")) "true")
|
||||||
@@ -1125,6 +1128,258 @@
|
|||||||
(er-eval-test "lists:duplicate val"
|
(er-eval-test "lists:duplicate val"
|
||||||
(nm (ev "hd(lists:duplicate(3, marker))")) "marker")
|
(nm (ev "hd(lists:duplicate(3, marker))")) "marker")
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 7: code:load_binary/3 ───────────────────────────────
|
||||||
|
(er-modules-reset!)
|
||||||
|
|
||||||
|
(er-eval-test "code:load_binary ok tag"
|
||||||
|
(nm (ev "element(1, code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 1.\"))"))
|
||||||
|
"module")
|
||||||
|
(er-eval-test "code:load_binary ok name"
|
||||||
|
(nm (ev "element(2, code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 1.\"))"))
|
||||||
|
"cl1")
|
||||||
|
(er-eval-test "code:load_binary then call"
|
||||||
|
(ev "cl1:foo()") 1)
|
||||||
|
|
||||||
|
(er-eval-test "code:load_binary reload v2"
|
||||||
|
(ev "code:load_binary(cl1, \"cl1.erl\", \"-module(cl1). foo() -> 99.\"), cl1:foo()")
|
||||||
|
99)
|
||||||
|
|
||||||
|
(er-eval-test "code:load_binary name mismatch tag"
|
||||||
|
(nm (ev "element(1, code:load_binary(cl2, \"x.erl\", \"-module(other). f() -> 0.\"))"))
|
||||||
|
"error")
|
||||||
|
(er-eval-test "code:load_binary name mismatch reason"
|
||||||
|
(nm (ev "element(2, code:load_binary(cl2, \"x.erl\", \"-module(other). f() -> 0.\"))"))
|
||||||
|
"module_name_mismatch")
|
||||||
|
|
||||||
|
(er-eval-test "code:load_binary badfile on garbage"
|
||||||
|
(nm (ev "element(2, code:load_binary(cl3, \"x.erl\", \"this is not erlang\"))"))
|
||||||
|
"badfile")
|
||||||
|
|
||||||
|
(er-eval-test "code:load_binary non-atom mod is badarg"
|
||||||
|
(nm (ev "element(2, code:load_binary(\"cl1\", \"x.erl\", \"-module(cl1). f() -> 0.\"))"))
|
||||||
|
"badarg")
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 7: code:purge/1 + code:soft_purge/1 ───────────────────
|
||||||
|
(er-modules-reset!)
|
||||||
|
|
||||||
|
;; purge unknown module → false
|
||||||
|
(er-eval-test "code:purge unknown"
|
||||||
|
(nm (ev "code:purge(nope)")) "false")
|
||||||
|
|
||||||
|
;; load, then purge without old version → false (nothing to purge)
|
||||||
|
(er-eval-test "code:purge no old"
|
||||||
|
(nm (ev "code:load_binary(pg1, \"pg1\", \"-module(pg1). v() -> 1.\"), code:purge(pg1)"))
|
||||||
|
"false")
|
||||||
|
|
||||||
|
;; load v1, load v2 (creates :old), purge with no live procs → true
|
||||||
|
(er-eval-test "code:purge after reload"
|
||||||
|
(nm (ev "code:load_binary(pg2, \"pg2\", \"-module(pg2). v() -> 1.\"), code:load_binary(pg2, \"pg2\", \"-module(pg2). v() -> 2.\"), code:purge(pg2)"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
;; idempotent: purging again returns false (already purged)
|
||||||
|
(er-eval-test "code:purge twice"
|
||||||
|
(nm (ev "code:load_binary(pg3, \"pg3\", \"-module(pg3). v() -> 1.\"), code:load_binary(pg3, \"pg3\", \"-module(pg3). v() -> 2.\"), code:purge(pg3), code:purge(pg3)"))
|
||||||
|
"false")
|
||||||
|
|
||||||
|
;; purge returns true whenever an :old slot exists, regardless of process tracking
|
||||||
|
;; (proper "kill lingering" semantics requires spawn/3 which is still stubbed)
|
||||||
|
(er-eval-test "code:purge with old slot present"
|
||||||
|
(nm (ev "code:load_binary(pg4, \"pg4\", \"-module(pg4). loop() -> receive stop -> ok end.\"),
|
||||||
|
Pid = spawn(fun () -> pg4:loop() end),
|
||||||
|
code:load_binary(pg4, \"pg4\", \"-module(pg4). loop() -> receive stop -> done end.\"),
|
||||||
|
code:purge(pg4)"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
;; soft_purge unknown → true (nothing to purge)
|
||||||
|
(er-eval-test "code:soft_purge unknown"
|
||||||
|
(nm (ev "code:soft_purge(nope)")) "true")
|
||||||
|
|
||||||
|
;; soft_purge with no old version → true
|
||||||
|
(er-eval-test "code:soft_purge no old"
|
||||||
|
(nm (ev "code:load_binary(sp1, \"sp1\", \"-module(sp1). v() -> 1.\"), code:soft_purge(sp1)"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
;; soft_purge with old + no lingering procs → true (clears :old)
|
||||||
|
(er-eval-test "code:soft_purge clean"
|
||||||
|
(nm (ev "code:load_binary(sp2, \"sp2\", \"-module(sp2). v() -> 1.\"), code:load_binary(sp2, \"sp2\", \"-module(sp2). v() -> 2.\"), code:soft_purge(sp2)"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
;; non-atom Mod is badarg (raise)
|
||||||
|
(er-eval-test "code:purge badarg"
|
||||||
|
(nm (ev "try code:purge(\"str\") catch error:badarg -> ok end")) "ok")
|
||||||
|
(er-eval-test "code:soft_purge badarg"
|
||||||
|
(nm (ev "try code:soft_purge(123) catch error:badarg -> ok end")) "ok")
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 7: code:which/1 + code:is_loaded/1 + code:all_loaded/0 ──
|
||||||
|
(er-modules-reset!)
|
||||||
|
|
||||||
|
(er-eval-test "code:which non_existing"
|
||||||
|
(nm (ev "code:which(nope)")) "non_existing")
|
||||||
|
|
||||||
|
(er-eval-test "code:which after load"
|
||||||
|
(nm (ev "code:load_binary(wh1, \"wh1\", \"-module(wh1). v() -> 1.\"), code:which(wh1)"))
|
||||||
|
"loaded")
|
||||||
|
|
||||||
|
(er-eval-test "code:is_loaded missing"
|
||||||
|
(nm (ev "code:is_loaded(nope)")) "false")
|
||||||
|
|
||||||
|
(er-eval-test "code:is_loaded tag"
|
||||||
|
(nm (ev "code:load_binary(il1, \"il1\", \"-module(il1). v() -> 1.\"), element(1, code:is_loaded(il1))"))
|
||||||
|
"file")
|
||||||
|
|
||||||
|
(er-eval-test "code:is_loaded value"
|
||||||
|
(nm (ev "code:load_binary(il2, \"il2\", \"-module(il2). v() -> 1.\"), element(2, code:is_loaded(il2))"))
|
||||||
|
"loaded")
|
||||||
|
|
||||||
|
(er-modules-reset!)
|
||||||
|
(er-eval-test "code:all_loaded empty"
|
||||||
|
(ev "length(code:all_loaded())") 0)
|
||||||
|
|
||||||
|
(er-modules-reset!)
|
||||||
|
(er-eval-test "code:all_loaded count"
|
||||||
|
(ev "code:load_binary(al1, \"al1\", \"-module(al1). v() -> 1.\"),
|
||||||
|
code:load_binary(al2, \"al2\", \"-module(al2). v() -> 1.\"),
|
||||||
|
length(code:all_loaded())")
|
||||||
|
2)
|
||||||
|
|
||||||
|
(er-eval-test "code:all_loaded first entry tag"
|
||||||
|
(nm (ev "code:load_binary(al3, \"al3\", \"-module(al3). v() -> 1.\"),
|
||||||
|
element(2, hd(code:all_loaded()))"))
|
||||||
|
"loaded")
|
||||||
|
|
||||||
|
(er-eval-test "code:which badarg"
|
||||||
|
(nm (ev "try code:which(\"str\") catch error:badarg -> ok end")) "ok")
|
||||||
|
(er-eval-test "code:is_loaded badarg"
|
||||||
|
(nm (ev "try code:is_loaded(123) catch error:badarg -> ok end")) "ok")
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 7: hot-reload call dispatch semantics ──────────────────
|
||||||
|
;; Cross-module M:F() calls always hit the CURRENT version;
|
||||||
|
;; local F() calls inside a module body resolve through the env
|
||||||
|
;; the function closed over (i.e. the version it was loaded with).
|
||||||
|
|
||||||
|
(er-modules-reset!)
|
||||||
|
|
||||||
|
;; M:F always hits current
|
||||||
|
(er-eval-test "cross-mod after reload v2"
|
||||||
|
(ev "code:load_binary(hr1, \"hr1\", \"-module(hr1). f() -> 1.\"),
|
||||||
|
code:load_binary(hr1, \"hr1\", \"-module(hr1). f() -> 2.\"),
|
||||||
|
hr1:f()")
|
||||||
|
2)
|
||||||
|
|
||||||
|
;; Local call inside reloaded module body resolves via fresh mod-env
|
||||||
|
;; (a() does a local b(); b() got upgraded too)
|
||||||
|
(er-eval-test "local call inside reloaded module body"
|
||||||
|
(ev "code:load_binary(hr2, \"hr2\", \"-module(hr2). a() -> b(). b() -> 1.\"),
|
||||||
|
code:load_binary(hr2, \"hr2\", \"-module(hr2). a() -> b(). b() -> 99.\"),
|
||||||
|
hr2:a()")
|
||||||
|
99)
|
||||||
|
|
||||||
|
;; Fun captured BEFORE reload, with local-call body, keeps v1 semantics
|
||||||
|
(er-eval-test "captured fun keeps closed-over env (local call)"
|
||||||
|
(ev "code:load_binary(hr3, \"hr3\", \"-module(hr3). get_fn() -> fun () -> b() end. b() -> 1.\"),
|
||||||
|
Fn = hr3:get_fn(),
|
||||||
|
code:load_binary(hr3, \"hr3\", \"-module(hr3). get_fn() -> fun () -> b() end. b() -> 99.\"),
|
||||||
|
Fn()")
|
||||||
|
1)
|
||||||
|
|
||||||
|
;; Fun captured BEFORE reload, with CROSS-mod body, sees v2's current
|
||||||
|
(er-eval-test "captured fun follows cross-mod to current"
|
||||||
|
(ev "code:load_binary(hr4, \"hr4\", \"-module(hr4). get_xref() -> fun () -> hr4:b() end. b() -> 1.\"),
|
||||||
|
Fn = hr4:get_xref(),
|
||||||
|
code:load_binary(hr4, \"hr4\", \"-module(hr4). get_xref() -> fun () -> hr4:b() end. b() -> 99.\"),
|
||||||
|
Fn()")
|
||||||
|
99)
|
||||||
|
|
||||||
|
;; Two captured funs from two different vintages
|
||||||
|
(er-eval-test "two funs from two vintages stay independent"
|
||||||
|
(ev "code:load_binary(hr5, \"hr5\", \"-module(hr5). gf() -> fun () -> v() end. v() -> 10.\"),
|
||||||
|
F1 = hr5:gf(),
|
||||||
|
code:load_binary(hr5, \"hr5\", \"-module(hr5). gf() -> fun () -> v() end. v() -> 20.\"),
|
||||||
|
F2 = hr5:gf(),
|
||||||
|
F1() + F2()")
|
||||||
|
30)
|
||||||
|
|
||||||
|
;; Version slot bumps correctly when a captured fun stays alive
|
||||||
|
(er-eval-test "version bumps despite captured funs"
|
||||||
|
(ev "code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 1.\"),
|
||||||
|
_Pinned = hr6:gf(),
|
||||||
|
code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 2.\"),
|
||||||
|
code:load_binary(hr6, \"hr6\", \"-module(hr6). gf() -> fun () -> v() end. v() -> 3.\"),
|
||||||
|
hr6:v()")
|
||||||
|
3)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 7 capstone: full hot-reload ladder ───────────────────
|
||||||
|
;; Load v1 → spawn from inside module → load v2 → cross-mod hits v2 →
|
||||||
|
;; local call inside v1 process still resolves v1 → soft_purge refuses
|
||||||
|
;; while v1 procs alive → purge kills them.
|
||||||
|
;;
|
||||||
|
;; All stages must run in a single erlang-eval-ast call: each call resets
|
||||||
|
;; the scheduler (er-sched-init!) so cross-call Pid handles would point at
|
||||||
|
;; reaped processes.
|
||||||
|
(er-modules-reset!)
|
||||||
|
|
||||||
|
(define er-rt-cap-prog "code:load_binary(cap, \"cap.erl\", \"-module(cap). start() -> spawn(fun () -> loop() end). loop() -> receive {ping, From} -> From ! {pong, v1}, loop(); stop -> done end. tag() -> v1.\"), Tag1 = cap:tag(), Pid1 = cap:start(), code:load_binary(cap, \"cap.erl\", \"-module(cap). start() -> spawn(fun () -> loop() end). loop() -> receive {ping, From} -> From ! {pong, v2}, loop(); stop -> done end. tag() -> v2.\"), Tag2 = cap:tag(), _Pid2 = cap:start(), Soft1 = code:soft_purge(cap), Hard = code:purge(cap), Soft2 = code:soft_purge(cap), {Tag1, Tag2, Soft1, Hard, Soft2}")
|
||||||
|
|
||||||
|
(define er-rt-cap-result (ev er-rt-cap-prog))
|
||||||
|
|
||||||
|
(er-eval-test "capstone v1 tag direct"
|
||||||
|
(get (nth (get er-rt-cap-result :elements) 0) :name) "v1")
|
||||||
|
|
||||||
|
(er-eval-test "capstone v2 tag"
|
||||||
|
(get (nth (get er-rt-cap-result :elements) 1) :name) "v2")
|
||||||
|
|
||||||
|
(er-eval-test "capstone soft_purge while v1 alive = false"
|
||||||
|
(get (nth (get er-rt-cap-result :elements) 2) :name) "false")
|
||||||
|
|
||||||
|
(er-eval-test "capstone hard purge = true"
|
||||||
|
(get (nth (get er-rt-cap-result :elements) 3) :name) "true")
|
||||||
|
|
||||||
|
(er-eval-test "capstone soft_purge clean after hard = true"
|
||||||
|
(get (nth (get er-rt-cap-result :elements) 4) :name) "true")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
;; ── $X char literals (Step 3b substrate fix 2026-06-04) ──────────
|
||||||
|
(er-eval-test "char $A" (ev "$A") 65)
|
||||||
|
(er-eval-test "char $a" (ev "$a") 97)
|
||||||
|
(er-eval-test "char $0 is digit, not escape-NUL" (ev "$0") 48)
|
||||||
|
(er-eval-test "char $\\n is newline (10)" (ev "$\\n") 10)
|
||||||
|
(er-eval-test "char $\\t is tab (9)" (ev "$\\t") 9)
|
||||||
|
(er-eval-test "char $\\r is CR (13)" (ev "$\\r") 13)
|
||||||
|
(er-eval-test "char $\\s is space (32)" (ev "$\\s") 32)
|
||||||
|
(er-eval-test "char $\\0 is NUL (0)" (ev "$\\0") 0)
|
||||||
|
(er-eval-test "char $\\\\ is backslash (92)" (ev "$\\\\") 92)
|
||||||
|
(er-eval-test "[$h,$i] head is 104" (ev "hd([$h, $i])") 104)
|
||||||
|
(er-eval-test "list_to_binary char-list -> bytes"
|
||||||
|
(ev "byte_size(list_to_binary([$f, $e, $d]))") 3)
|
||||||
|
(er-eval-test "list_to_binary char-list round-trip"
|
||||||
|
(nm (ev "list_to_binary([$h, $i]) =:= <<104, 105>>")) "true")
|
||||||
|
|
||||||
|
|
||||||
|
;; ── atom_to_list / integer_to_list charlist semantics (Step 3b substrate fix #3) ──
|
||||||
|
(er-eval-test "atom_to_list hd is char code"
|
||||||
|
(ev "hd(atom_to_list(hi))") 104)
|
||||||
|
(er-eval-test "atom_to_list maps to bytes via list_to_binary"
|
||||||
|
(ev "byte_size(list_to_binary(atom_to_list(hello)))") 5)
|
||||||
|
(er-eval-test "atom_to_list -> list_to_binary -> bytes content"
|
||||||
|
(nm (ev "list_to_binary(atom_to_list(ok)) =:= <<111, 107>>")) "true")
|
||||||
|
(er-eval-test "integer_to_list 12345 -> 5 chars"
|
||||||
|
(ev "length(integer_to_list(12345))") 5)
|
||||||
|
(er-eval-test "integer_to_list -> bytes -> back"
|
||||||
|
(ev "list_to_integer(integer_to_list(99999))") 99999)
|
||||||
|
(er-eval-test "list_to_atom from charlist"
|
||||||
|
(nm (ev "list_to_atom([$f, $o, $o])")) "foo")
|
||||||
|
(er-eval-test "list_to_atom from SX-string back-compat"
|
||||||
|
(nm (ev "list_to_atom(\"bar\")")) "bar")
|
||||||
|
(er-eval-test "list_to_integer from charlist"
|
||||||
|
(ev "list_to_integer([$1, $0, $0])") 100)
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-eval-test-summary
|
er-eval-test-summary
|
||||||
(str "eval " er-eval-test-pass "/" er-eval-test-count))
|
(str "eval " er-eval-test-pass "/" er-eval-test-count))
|
||||||
|
|||||||
223
lib/erlang/tests/ffi.sx
Normal file
223
lib/erlang/tests/ffi.sx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
;; Phase 8 FFI BIF tests — one round-trip per BIF.
|
||||||
|
;; Each BIF lives in lib/erlang/runtime.sx (registered with
|
||||||
|
;; er-bif-registry) and wraps an SX-host primitive.
|
||||||
|
|
||||||
|
(define er-ffi-test-count 0)
|
||||||
|
(define er-ffi-test-pass 0)
|
||||||
|
(define er-ffi-test-fails (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
er-ffi-test
|
||||||
|
(fn
|
||||||
|
(name actual expected)
|
||||||
|
(set! er-ffi-test-count (+ er-ffi-test-count 1))
|
||||||
|
(if
|
||||||
|
(= actual expected)
|
||||||
|
(set! er-ffi-test-pass (+ er-ffi-test-pass 1))
|
||||||
|
(append! er-ffi-test-fails {:name name :expected expected :actual actual}))))
|
||||||
|
|
||||||
|
(define ffi-ev erlang-eval-ast)
|
||||||
|
(define ffi-nm (fn (v) (get v :name)))
|
||||||
|
|
||||||
|
;; ── file:read_file/1 + file:write_file/2 ────────────────────────
|
||||||
|
(er-ffi-test
|
||||||
|
"file:write_file ok"
|
||||||
|
(ffi-nm (ffi-ev "file:write_file(\"/tmp/er-ffi-1.txt\", \"hello\")"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"file:read_file ok tag"
|
||||||
|
(ffi-nm (ffi-ev "element(1, file:read_file(\"/tmp/er-ffi-1.txt\"))"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"file:read_file payload is binary"
|
||||||
|
(ffi-nm
|
||||||
|
(ffi-ev
|
||||||
|
"case file:read_file(\"/tmp/er-ffi-1.txt\") of {ok, B} -> is_binary(B) end"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"file:read_file content byte_size"
|
||||||
|
(ffi-ev
|
||||||
|
"case file:read_file(\"/tmp/er-ffi-1.txt\") of {ok, B} -> byte_size(B) end")
|
||||||
|
5)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"file:read_file missing enoent"
|
||||||
|
(ffi-nm (ffi-ev "element(2, file:read_file(\"/tmp/er-ffi-no-such-xyz\"))"))
|
||||||
|
"enoent")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"file:write_file bad path enoent"
|
||||||
|
(ffi-nm
|
||||||
|
(ffi-ev "element(2, file:write_file(\"/tmp/er-ffi-no-dir-xyz/x\", \"y\"))"))
|
||||||
|
"enoent")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"file:write_file binary payload"
|
||||||
|
(ffi-ev
|
||||||
|
"file:write_file(\"/tmp/er-ffi-2.bin\", <<1, 2, 3, 4, 5>>), case file:read_file(\"/tmp/er-ffi-2.bin\") of {ok, B} -> byte_size(B) end")
|
||||||
|
5)
|
||||||
|
|
||||||
|
;; ── file:delete/1 ────────────────────────────────────────────────
|
||||||
|
(er-ffi-test
|
||||||
|
"file:delete ok"
|
||||||
|
(ffi-nm
|
||||||
|
(ffi-ev
|
||||||
|
"file:write_file(\"/tmp/er-ffi-del.txt\", \"x\"), file:delete(\"/tmp/er-ffi-del.txt\")"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"file:read_file after delete enoent"
|
||||||
|
(ffi-nm
|
||||||
|
(ffi-ev
|
||||||
|
"file:write_file(\"/tmp/er-ffi-del2.txt\", \"x\"), file:delete(\"/tmp/er-ffi-del2.txt\"), element(2, file:read_file(\"/tmp/er-ffi-del2.txt\"))"))
|
||||||
|
"enoent")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"crypto:hash sha256 -> 32-byte binary"
|
||||||
|
(ffi-ev "byte_size(crypto:hash(sha256, <<97,98,99>>))")
|
||||||
|
32)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"crypto:hash sha512 -> 64-byte binary"
|
||||||
|
(ffi-ev "byte_size(crypto:hash(sha512, <<97,98,99>>))")
|
||||||
|
64)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"crypto:hash sha3_256 is_binary"
|
||||||
|
(ffi-nm (ffi-ev "is_binary(crypto:hash(sha3_256, <<120>>))"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"crypto:hash deterministic"
|
||||||
|
(ffi-nm (ffi-ev "crypto:hash(sha256, <<97>>) =:= crypto:hash(sha256, <<97>>)"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"crypto:hash distinct inputs distinct digests"
|
||||||
|
(ffi-nm (ffi-ev "crypto:hash(sha256, <<97>>) =/= crypto:hash(sha256, <<98>>)"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"crypto:hash bad type -> error:badarg"
|
||||||
|
(ffi-nm (ffi-ev "try crypto:hash(md5, <<120>>) catch error:badarg -> ok end"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"cid:from_bytes is_binary"
|
||||||
|
(ffi-nm (ffi-ev "is_binary(cid:from_bytes(<<97,98,99>>))"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"cid:from_bytes deterministic"
|
||||||
|
(ffi-nm (ffi-ev "cid:from_bytes(<<97,98,99>>) =:= cid:from_bytes(<<97,98,99>>)"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"cid:from_bytes distinct inputs distinct CIDs"
|
||||||
|
(ffi-nm (ffi-ev "cid:from_bytes(<<97,98,99>>) =/= cid:from_bytes(<<97,98,100>>)"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"cid:from_bytes non-binary -> error:badarg"
|
||||||
|
(ffi-nm (ffi-ev "try cid:from_bytes(42) catch error:badarg -> ok end"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"cid:to_string is_binary"
|
||||||
|
(ffi-nm (ffi-ev "is_binary(cid:to_string({ok, 42}))"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"cid:to_string deterministic"
|
||||||
|
(ffi-nm (ffi-ev "cid:to_string(foo) =:= cid:to_string(foo)"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"cid:to_string distinct terms distinct CIDs"
|
||||||
|
(ffi-nm (ffi-ev "cid:to_string(foo) =/= cid:to_string(bar)"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"file:list_dir ok tag"
|
||||||
|
(ffi-nm (ffi-ev "element(1, file:list_dir(\"lib/erlang\"))"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"file:list_dir non-empty"
|
||||||
|
(ffi-nm (ffi-ev "case file:list_dir(\"lib/erlang\") of {ok, L} -> length(L) > 3 end"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"file:list_dir entries are binaries"
|
||||||
|
(ffi-nm (ffi-ev "case file:list_dir(\"lib/erlang\") of {ok, L} -> is_binary(hd(L)) end"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"file:list_dir missing enoent"
|
||||||
|
(ffi-nm (ffi-ev "element(2, file:list_dir(\"/no/such/dir/xyz\"))"))
|
||||||
|
"enoent")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"binary_to_list <<1,2,3>> length"
|
||||||
|
(ffi-ev "length(binary_to_list(<<1,2,3,4,5>>))")
|
||||||
|
5)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"binary_to_list hd byte"
|
||||||
|
(ffi-ev "hd(binary_to_list(<<7,8,9>>))")
|
||||||
|
7)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"binary_to_list empty -> []"
|
||||||
|
(ffi-nm (ffi-ev "case binary_to_list(<<>>) of [] -> empty end"))
|
||||||
|
"empty")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"list_to_binary flat list bytes"
|
||||||
|
(ffi-ev "byte_size(list_to_binary([1,2,3]))")
|
||||||
|
3)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"list_to_binary nested iolist"
|
||||||
|
(ffi-ev "byte_size(list_to_binary([1, <<2,3>>, [4, [5]]]))")
|
||||||
|
5)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"list_to_binary round-trip via binary_to_list"
|
||||||
|
(ffi-nm (ffi-ev "list_to_binary(binary_to_list(<<10,20,30>>)) =:= <<10,20,30>>"))
|
||||||
|
"true")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"binary_to_list non-binary -> error:badarg"
|
||||||
|
(ffi-nm (ffi-ev "try binary_to_list(42) catch error:badarg -> ok end"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"list_to_binary out-of-range byte -> error:badarg"
|
||||||
|
(ffi-nm (ffi-ev "try list_to_binary([300]) catch error:badarg -> ok end"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"list_to_binary non-iolist -> error:badarg"
|
||||||
|
(ffi-nm (ffi-ev "try list_to_binary(42) catch error:badarg -> ok end"))
|
||||||
|
"ok")
|
||||||
|
|
||||||
|
;; ── Still deferred (no host primitive): httpc (HTTP client, v2),
|
||||||
|
;; sqlite-* (v2 indexes). Assert NOT registered so a future iteration
|
||||||
|
;; that wires them without updating this suite fails fast.
|
||||||
|
(er-ffi-test
|
||||||
|
"httpc:request unregistered"
|
||||||
|
(er-lookup-bif "httpc" "request" 4)
|
||||||
|
nil)
|
||||||
|
|
||||||
|
(er-ffi-test
|
||||||
|
"sqlite:exec unregistered"
|
||||||
|
(er-lookup-bif "sqlite" "exec" 2)
|
||||||
|
nil)
|
||||||
|
|
||||||
|
(define
|
||||||
|
er-ffi-test-summary
|
||||||
|
(str "ffi " er-ffi-test-pass "/" er-ffi-test-count))
|
||||||
@@ -134,6 +134,144 @@
|
|||||||
(er-sched-current-pid)
|
(er-sched-current-pid)
|
||||||
nil)
|
nil)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 7: module-version slots ───────────────────────────────
|
||||||
|
(er-modules-reset!)
|
||||||
|
|
||||||
|
(define er-rt-slot1 (er-mk-module-slot (er-env-new) nil 1))
|
||||||
|
(er-rt-test "slot tag" (get er-rt-slot1 :tag) "module")
|
||||||
|
(er-rt-test "slot version" (er-module-version er-rt-slot1) 1)
|
||||||
|
(er-rt-test "slot old nil" (er-module-old-env er-rt-slot1) nil)
|
||||||
|
(er-rt-test "slot current not nil" (= (er-module-current-env er-rt-slot1) nil) false)
|
||||||
|
|
||||||
|
(erlang-load-module "-module(hr1). a() -> 1.")
|
||||||
|
(define er-rt-reg (er-modules-get))
|
||||||
|
(er-rt-test "registry has hr1" (dict-has? er-rt-reg "hr1") true)
|
||||||
|
(er-rt-test "v1 on first load" (er-module-version (get er-rt-reg "hr1")) 1)
|
||||||
|
(er-rt-test "v1 old is nil" (er-module-old-env (get er-rt-reg "hr1")) nil)
|
||||||
|
(er-rt-test "v1 current not nil" (= (er-module-current-env (get er-rt-reg "hr1")) nil) false)
|
||||||
|
|
||||||
|
(define er-rt-env-v1 (er-module-current-env (get er-rt-reg "hr1")))
|
||||||
|
(erlang-load-module "-module(hr1). a() -> 2.")
|
||||||
|
(er-rt-test "v2 on second load" (er-module-version (get er-rt-reg "hr1")) 2)
|
||||||
|
(er-rt-test "v2 old is v1 env" (er-module-old-env (get er-rt-reg "hr1")) er-rt-env-v1)
|
||||||
|
(er-rt-test "v2 current is new" (= (er-module-current-env (get er-rt-reg "hr1")) er-rt-env-v1) false)
|
||||||
|
|
||||||
|
(erlang-load-module "-module(hr1). a() -> 3.")
|
||||||
|
(er-rt-test "v3 on third load" (er-module-version (get er-rt-reg "hr1")) 3)
|
||||||
|
|
||||||
|
(er-modules-reset!)
|
||||||
|
(er-rt-test "registry-reset clears" (dict-has? (er-modules-get) "hr1") false)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 8: FFI BIF registry ──────────────────────────────────
|
||||||
|
(er-bif-registry-reset!)
|
||||||
|
|
||||||
|
(er-rt-test "empty registry" (len (er-list-bifs)) 0)
|
||||||
|
(er-rt-test "lookup miss" (er-lookup-bif "crypto" "hash" 2) nil)
|
||||||
|
|
||||||
|
(er-register-bif! "fake" "echo" 1 (fn (vs) (nth vs 0)))
|
||||||
|
(er-rt-test "register grows registry" (len (er-list-bifs)) 1)
|
||||||
|
|
||||||
|
(define er-rt-bif-hit (er-lookup-bif "fake" "echo" 1))
|
||||||
|
(er-rt-test "lookup hit module" (get er-rt-bif-hit :module) "fake")
|
||||||
|
(er-rt-test "lookup hit name" (get er-rt-bif-hit :name) "echo")
|
||||||
|
(er-rt-test "lookup hit arity" (get er-rt-bif-hit :arity) 1)
|
||||||
|
(er-rt-test "lookup hit pure?" (get er-rt-bif-hit :pure?) false)
|
||||||
|
|
||||||
|
(er-rt-test "fn invocable" ((get er-rt-bif-hit :fn) (list 42)) 42)
|
||||||
|
|
||||||
|
;; Re-register replaces (same key)
|
||||||
|
(er-register-bif! "fake" "echo" 1 (fn (vs) "replaced"))
|
||||||
|
(er-rt-test "re-register same key, count unchanged" (len (er-list-bifs)) 1)
|
||||||
|
(er-rt-test "re-register replaces fn"
|
||||||
|
((get (er-lookup-bif "fake" "echo" 1) :fn) (list 99)) "replaced")
|
||||||
|
|
||||||
|
;; Pure variant
|
||||||
|
(er-register-pure-bif! "fake" "pure" 2 (fn (vs) (+ (nth vs 0) (nth vs 1))))
|
||||||
|
(er-rt-test "pure registered separately, count 2" (len (er-list-bifs)) 2)
|
||||||
|
(er-rt-test "pure flag true"
|
||||||
|
(get (er-lookup-bif "fake" "pure" 2) :pure?) true)
|
||||||
|
(er-rt-test "pure fn invocable"
|
||||||
|
((get (er-lookup-bif "fake" "pure" 2) :fn) (list 7 8)) 15)
|
||||||
|
|
||||||
|
;; Arity disambiguation: same module+name, different arity = distinct entries
|
||||||
|
(er-register-bif! "fake" "echo" 2 (fn (vs) (list (nth vs 0) (nth vs 1))))
|
||||||
|
(er-rt-test "arity disambiguation count" (len (er-list-bifs)) 3)
|
||||||
|
(er-rt-test "arity-1 lookup still works"
|
||||||
|
((get (er-lookup-bif "fake" "echo" 1) :fn) (list 11)) "replaced")
|
||||||
|
(er-rt-test "arity-2 lookup independent"
|
||||||
|
(len ((get (er-lookup-bif "fake" "echo" 2) :fn) (list 1 2))) 2)
|
||||||
|
|
||||||
|
;; Reset clears the registry
|
||||||
|
(er-bif-registry-reset!)
|
||||||
|
(er-rt-test "reset clears" (len (er-list-bifs)) 0)
|
||||||
|
(er-rt-test "reset lookup nil" (er-lookup-bif "fake" "echo" 1) nil)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 8: term marshalling (er-to-sx / er-of-sx) ─────────────
|
||||||
|
|
||||||
|
;; er-to-sx: Erlang → SX
|
||||||
|
(er-rt-test "to-sx atom" (er-to-sx (er-mk-atom "foo")) (make-symbol "foo"))
|
||||||
|
(er-rt-test "to-sx atom is symbol" (type-of (er-to-sx (er-mk-atom "x"))) "symbol")
|
||||||
|
(er-rt-test "to-sx nil" (er-to-sx (er-mk-nil)) (list))
|
||||||
|
(er-rt-test "to-sx integer passthrough" (er-to-sx 42) 42)
|
||||||
|
(er-rt-test "to-sx float passthrough" (er-to-sx 3.14) 3.14)
|
||||||
|
(er-rt-test "to-sx boolean passthrough" (er-to-sx true) true)
|
||||||
|
(er-rt-test "to-sx binary → string"
|
||||||
|
(er-to-sx (er-mk-binary (list 104 105 33))) "hi!")
|
||||||
|
(er-rt-test "to-sx cons → list"
|
||||||
|
(er-to-sx (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))) (list 1 2 3))
|
||||||
|
(er-rt-test "to-sx tuple → list"
|
||||||
|
(er-to-sx (er-mk-tuple (list 1 2 3))) (list 1 2 3))
|
||||||
|
(er-rt-test "to-sx nested cons"
|
||||||
|
(er-to-sx (er-mk-cons (er-mk-atom "a") (er-mk-cons 7 (er-mk-nil))))
|
||||||
|
(list (make-symbol "a") 7))
|
||||||
|
|
||||||
|
;; er-of-sx: SX → Erlang
|
||||||
|
(er-rt-test "of-sx symbol"
|
||||||
|
(get (er-of-sx (make-symbol "ok")) :name) "ok")
|
||||||
|
(er-rt-test "of-sx symbol is atom"
|
||||||
|
(er-atom? (er-of-sx (make-symbol "x"))) true)
|
||||||
|
(er-rt-test "of-sx string is binary"
|
||||||
|
(er-binary? (er-of-sx "hi")) true)
|
||||||
|
(er-rt-test "of-sx string bytes"
|
||||||
|
(get (er-of-sx "hi") :bytes) (list 104 105))
|
||||||
|
(er-rt-test "of-sx integer passthrough"
|
||||||
|
(er-of-sx 42) 42)
|
||||||
|
(er-rt-test "of-sx empty list → nil"
|
||||||
|
(er-nil? (er-of-sx (list))) true)
|
||||||
|
(er-rt-test "of-sx list → cons chain length"
|
||||||
|
(er-list-length (er-of-sx (list 1 2 3 4))) 4)
|
||||||
|
(er-rt-test "of-sx list head/tail"
|
||||||
|
(get (er-of-sx (list 10 20)) :head) 10)
|
||||||
|
|
||||||
|
;; Round-trips
|
||||||
|
(er-rt-test "rtrip integer" (er-to-sx (er-of-sx 99)) 99)
|
||||||
|
(er-rt-test "rtrip atom"
|
||||||
|
(get (er-of-sx (er-to-sx (er-mk-atom "abc"))) :name) "abc")
|
||||||
|
(er-rt-test "rtrip binary bytes"
|
||||||
|
(get (er-of-sx (er-to-sx (er-mk-binary (list 1 2 3)))) :bytes) (list 1 2 3))
|
||||||
|
(er-rt-test "rtrip cons-of-ints length"
|
||||||
|
(er-list-length (er-of-sx (er-to-sx
|
||||||
|
(er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))))) 3)
|
||||||
|
|
||||||
|
;; Tuples don't round-trip exactly (er-to-sx flattens tuples to lists);
|
||||||
|
;; documented one-way conversion.
|
||||||
|
(er-rt-test "to-sx of tuple loses tag"
|
||||||
|
(er-cons? (er-of-sx (er-to-sx (er-mk-tuple (list 1 2 3))))) true)
|
||||||
|
|
||||||
|
|
||||||
|
;; Re-populate built-in BIFs so subsequent test files (ring, ping-pong, etc.)
|
||||||
|
;; can call length/spawn/etc. The migration onto the registry means a reset
|
||||||
|
;; here would otherwise break the rest of the conformance suite.
|
||||||
|
(er-register-builtin-bifs!)
|
||||||
|
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-rt-test-summary
|
er-rt-test-summary
|
||||||
(str "runtime " er-rt-test-pass "/" er-rt-test-count))
|
(str "runtime " er-rt-test-pass "/" er-rt-test-count))
|
||||||
|
|||||||
163
lib/erlang/tests/send_after.sx
Normal file
163
lib/erlang/tests/send_after.sx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
;; erlang:send_after / cancel_timer — timer primitives.
|
||||||
|
;;
|
||||||
|
;; A process schedules a message to itself (or another pid / registered
|
||||||
|
;; name) after N logical milliseconds. `cancel_timer` removes a pending
|
||||||
|
;; timer and reports the time left. These are the same primitives the
|
||||||
|
;; gen_server library uses to implement `{noreply, State, Timeout}`.
|
||||||
|
;;
|
||||||
|
;; The scheduler runs a synchronous logical clock (see runtime.sx
|
||||||
|
;; `er-sched-advance-time!`): time advances only when the runnable
|
||||||
|
;; queue drains, jumping to the earliest pending deadline. That makes
|
||||||
|
;; delivery deterministic and time-travel-safe — no wall clock.
|
||||||
|
|
||||||
|
(define er-sa-test-count 0)
|
||||||
|
(define er-sa-test-pass 0)
|
||||||
|
(define er-sa-test-fails (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
er-sa-test
|
||||||
|
(fn
|
||||||
|
(name actual expected)
|
||||||
|
(set! er-sa-test-count (+ er-sa-test-count 1))
|
||||||
|
(if
|
||||||
|
(= actual expected)
|
||||||
|
(set! er-sa-test-pass (+ er-sa-test-pass 1))
|
||||||
|
(append!
|
||||||
|
er-sa-test-fails
|
||||||
|
{:actual actual :expected expected :name name}))))
|
||||||
|
|
||||||
|
(define er-sa-pred
|
||||||
|
(fn (name actual) (er-sa-test name (if actual true false) true)))
|
||||||
|
|
||||||
|
(define sa-ev erlang-eval-ast)
|
||||||
|
|
||||||
|
;; ── T1 — schedule a self-message, receive it after the deadline ──
|
||||||
|
;; send_after returns a reference handle.
|
||||||
|
(er-sa-pred
|
||||||
|
"T1 send_after returns a ref"
|
||||||
|
(er-ref?
|
||||||
|
(sa-ev "erlang:send_after(50, self(), hello)")))
|
||||||
|
|
||||||
|
;; The scheduled message lands and a plain receive picks it up.
|
||||||
|
(er-sa-test
|
||||||
|
"T1 delivered message received"
|
||||||
|
(get
|
||||||
|
(sa-ev
|
||||||
|
"erlang:send_after(50, self(), hello),
|
||||||
|
receive M -> M end")
|
||||||
|
:name)
|
||||||
|
"hello")
|
||||||
|
|
||||||
|
;; Logical time advances exactly to the timer deadline (50ms) by the
|
||||||
|
;; time the message is received — round-trip latency well under 100ms.
|
||||||
|
(er-sa-test
|
||||||
|
"T1 clock at deadline on receipt"
|
||||||
|
(sa-ev
|
||||||
|
"erlang:send_after(50, self(), hello),
|
||||||
|
receive hello -> erlang:monotonic_time() end")
|
||||||
|
50)
|
||||||
|
|
||||||
|
;; ── T2 — cancel_timer returns remaining ms; message never arrives ──
|
||||||
|
;; Cancel immediately after scheduling: clock has not advanced, so the
|
||||||
|
;; full duration (~1000ms) is reported as remaining.
|
||||||
|
(er-sa-test
|
||||||
|
"T2 cancel returns remaining ms"
|
||||||
|
(sa-ev
|
||||||
|
"Ref = erlang:send_after(1000, self(), late),
|
||||||
|
erlang:cancel_timer(Ref)")
|
||||||
|
1000)
|
||||||
|
|
||||||
|
;; The cancelled timer never delivers — the receive falls through to
|
||||||
|
;; its `after` clause and returns `none`.
|
||||||
|
(er-sa-test
|
||||||
|
"T2 cancelled message never arrives"
|
||||||
|
(get
|
||||||
|
(sa-ev
|
||||||
|
"Ref = erlang:send_after(1000, self(), late),
|
||||||
|
erlang:cancel_timer(Ref),
|
||||||
|
receive late -> got after 50 -> none end")
|
||||||
|
:name)
|
||||||
|
"none")
|
||||||
|
|
||||||
|
;; ── T3 — multiple timers fire in deadline order, not schedule order ──
|
||||||
|
;; `b` is scheduled first (deadline 80) but `a` second (deadline 20).
|
||||||
|
;; Two plain receives drain the mailbox in arrival order — and arrival
|
||||||
|
;; is governed by deadline, so the first message out is `a`.
|
||||||
|
(er-sa-test
|
||||||
|
"T3 timers fire in deadline order"
|
||||||
|
(er-format-value
|
||||||
|
(sa-ev
|
||||||
|
"erlang:send_after(80, self(), b),
|
||||||
|
erlang:send_after(20, self(), a),
|
||||||
|
X = receive M1 -> M1 end,
|
||||||
|
Y = receive M2 -> M2 end,
|
||||||
|
{X, Y}"))
|
||||||
|
"{a,b}")
|
||||||
|
|
||||||
|
;; A selective receive on `a` matches the earlier-deadline timer even
|
||||||
|
;; though `b` was scheduled first.
|
||||||
|
(er-sa-test
|
||||||
|
"T3 selective receive picks earliest deadline"
|
||||||
|
(get
|
||||||
|
(sa-ev
|
||||||
|
"erlang:send_after(80, self(), b),
|
||||||
|
erlang:send_after(20, self(), a),
|
||||||
|
receive a -> first end")
|
||||||
|
:name)
|
||||||
|
"first")
|
||||||
|
|
||||||
|
;; ── T4 — cancel_timer on an already-fired timer returns false ──────
|
||||||
|
;; Once `x` has been received the timer has fired; cancelling its ref
|
||||||
|
;; now yields the atom `false`.
|
||||||
|
(er-sa-test
|
||||||
|
"T4 cancel of fired timer is false"
|
||||||
|
(get
|
||||||
|
(sa-ev
|
||||||
|
"Ref = erlang:send_after(20, self(), x),
|
||||||
|
receive x -> ok end,
|
||||||
|
erlang:cancel_timer(Ref)")
|
||||||
|
:name)
|
||||||
|
"false")
|
||||||
|
|
||||||
|
;; ── T5 — send_after to a registered atom name ──────────────────────
|
||||||
|
;; A second process registers itself as `srv`; the timer addresses it
|
||||||
|
;; by name, and the delayed message lands in that process's mailbox.
|
||||||
|
;; The server forwards what it got back to the parent for inspection.
|
||||||
|
(er-sa-test
|
||||||
|
"T5 timer delivers to registered name"
|
||||||
|
(get
|
||||||
|
(sa-ev
|
||||||
|
"Me = self(),
|
||||||
|
Pid = spawn(fun () -> receive M -> Me ! {got, M} end end),
|
||||||
|
register(srv, Pid),
|
||||||
|
erlang:send_after(20, srv, ping),
|
||||||
|
receive {got, X} -> X end")
|
||||||
|
:name)
|
||||||
|
"ping")
|
||||||
|
|
||||||
|
;; ── T6 — gen_server {noreply, State, Timeout} hookup ───────────────
|
||||||
|
;; A gen_server that, on the `arm` cast, returns {noreply, S, 100}.
|
||||||
|
;; The library schedules {timeout} to itself via send_after; when no
|
||||||
|
;; other message arrives first, handle_info({timeout}, S) fires. The
|
||||||
|
;; handler signals the parent so we can confirm the timeout landed.
|
||||||
|
(do
|
||||||
|
(er-load-gen-server!)
|
||||||
|
(erlang-load-module
|
||||||
|
"-module(sa_tmo).
|
||||||
|
init(Me) -> {ok, Me}.
|
||||||
|
handle_call(_R, _F, S) -> {reply, ok, S}.
|
||||||
|
handle_cast(arm, Me) -> {noreply, Me, 100}.
|
||||||
|
handle_info({timeout}, Me) -> Me ! fired, {noreply, Me};
|
||||||
|
handle_info(_M, S) -> {noreply, S}.")
|
||||||
|
nil)
|
||||||
|
|
||||||
|
(er-sa-test
|
||||||
|
"T6 gen_server timeout fires handle_info"
|
||||||
|
(get
|
||||||
|
(sa-ev
|
||||||
|
"Me = self(),
|
||||||
|
P = gen_server:start_link(sa_tmo, Me),
|
||||||
|
gen_server:cast(P, arm),
|
||||||
|
receive fired -> ok after 5000 -> timeout end")
|
||||||
|
:name)
|
||||||
|
"ok")
|
||||||
403
lib/erlang/tests/vm.sx
Normal file
403
lib/erlang/tests/vm.sx
Normal file
@@ -0,0 +1,403 @@
|
|||||||
|
;; Phase 9 — stub VM opcode dispatcher tests.
|
||||||
|
;; Verifies the dispatcher shape (mirrors plans/sx-vm-opcode-extension.md
|
||||||
|
;; for when 9a integrates) and the three pattern-match opcodes (9b)
|
||||||
|
;; route to the correct er-match-* impl.
|
||||||
|
|
||||||
|
(define er-vm-test-count 0)
|
||||||
|
(define er-vm-test-pass 0)
|
||||||
|
(define er-vm-test-fails (list))
|
||||||
|
|
||||||
|
(define
|
||||||
|
er-vm-test
|
||||||
|
(fn
|
||||||
|
(name actual expected)
|
||||||
|
(set! er-vm-test-count (+ er-vm-test-count 1))
|
||||||
|
(if
|
||||||
|
(= actual expected)
|
||||||
|
(set! er-vm-test-pass (+ er-vm-test-pass 1))
|
||||||
|
(append! er-vm-test-fails {:name name :expected expected :actual actual}))))
|
||||||
|
|
||||||
|
;; ── dispatcher core ─────────────────────────────────────────────
|
||||||
|
(er-vm-test
|
||||||
|
"tuple opcode registered"
|
||||||
|
(= (er-vm-lookup-opcode-by-id 128) nil)
|
||||||
|
false)
|
||||||
|
|
||||||
|
(er-vm-test
|
||||||
|
"tuple opcode name"
|
||||||
|
(get (er-vm-lookup-opcode-by-id 128) :name)
|
||||||
|
"OP_PATTERN_TUPLE")
|
||||||
|
|
||||||
|
(er-vm-test
|
||||||
|
"list opcode by name"
|
||||||
|
(get (er-vm-lookup-opcode-by-name "OP_PATTERN_LIST") :id)
|
||||||
|
129)
|
||||||
|
|
||||||
|
(er-vm-test
|
||||||
|
"binary opcode by name"
|
||||||
|
(get (er-vm-lookup-opcode-by-name "OP_PATTERN_BINARY") :id)
|
||||||
|
130)
|
||||||
|
|
||||||
|
(er-vm-test "lookup miss by id" (er-vm-lookup-opcode-by-id 999) nil)
|
||||||
|
|
||||||
|
(er-vm-test "lookup miss by name" (er-vm-lookup-opcode-by-name "OP_NOPE") nil)
|
||||||
|
|
||||||
|
(er-vm-test
|
||||||
|
"opcode list has 3+"
|
||||||
|
(>= (len (er-vm-list-opcodes)) 3)
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; ── OP_PATTERN_TUPLE ────────────────────────────────────────────
|
||||||
|
;; Pattern: {ok, X} matches value {ok, 42} → X bound to 42
|
||||||
|
(define er-vm-t1-env (er-env-new))
|
||||||
|
(define er-vm-t1-pat {:type "tuple" :elements (list {:type "atom" :value "ok"} {:name "X" :type "var"})})
|
||||||
|
(define er-vm-t1-val (er-mk-tuple (list (er-mk-atom "ok") 42)))
|
||||||
|
(er-vm-test
|
||||||
|
"OP_PATTERN_TUPLE match"
|
||||||
|
(er-vm-dispatch 128 (list er-vm-t1-pat er-vm-t1-val er-vm-t1-env))
|
||||||
|
true)
|
||||||
|
(er-vm-test "OP_PATTERN_TUPLE binds var" (get er-vm-t1-env "X") 42)
|
||||||
|
|
||||||
|
;; Same pattern against {error, ...} → false
|
||||||
|
(define er-vm-t2-env (er-env-new))
|
||||||
|
(define er-vm-t2-val (er-mk-tuple (list (er-mk-atom "error") 7)))
|
||||||
|
(er-vm-test
|
||||||
|
"OP_PATTERN_TUPLE no-match"
|
||||||
|
(er-vm-dispatch 128 (list er-vm-t1-pat er-vm-t2-val er-vm-t2-env))
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; Wrong arity tuple — pattern has 2 elements, value has 3
|
||||||
|
(define er-vm-t3-env (er-env-new))
|
||||||
|
(define
|
||||||
|
er-vm-t3-val
|
||||||
|
(er-mk-tuple (list (er-mk-atom "ok") 1 2)))
|
||||||
|
(er-vm-test
|
||||||
|
"OP_PATTERN_TUPLE arity mismatch"
|
||||||
|
(er-vm-dispatch 128 (list er-vm-t1-pat er-vm-t3-val er-vm-t3-env))
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; ── OP_PATTERN_LIST (cons) ──────────────────────────────────────
|
||||||
|
;; Pattern: [H | T] matches [1, 2, 3] → H=1, T=[2,3]
|
||||||
|
(define er-vm-l1-env (er-env-new))
|
||||||
|
(define er-vm-l1-pat {:type "cons" :tail {:name "T" :type "var"} :head {:name "H" :type "var"}})
|
||||||
|
(define
|
||||||
|
er-vm-l1-val
|
||||||
|
(er-mk-cons
|
||||||
|
1
|
||||||
|
(er-mk-cons 2 (er-mk-cons 3 (er-mk-nil)))))
|
||||||
|
(er-vm-test
|
||||||
|
"OP_PATTERN_LIST match"
|
||||||
|
(er-vm-dispatch 129 (list er-vm-l1-pat er-vm-l1-val er-vm-l1-env))
|
||||||
|
true)
|
||||||
|
(er-vm-test "OP_PATTERN_LIST binds head" (get er-vm-l1-env "H") 1)
|
||||||
|
(er-vm-test
|
||||||
|
"OP_PATTERN_LIST tail is cons"
|
||||||
|
(er-cons? (get er-vm-l1-env "T"))
|
||||||
|
true)
|
||||||
|
|
||||||
|
;; [H|T] against empty list → false
|
||||||
|
(define er-vm-l2-env (er-env-new))
|
||||||
|
(er-vm-test
|
||||||
|
"OP_PATTERN_LIST no-match on nil"
|
||||||
|
(er-vm-dispatch 129 (list er-vm-l1-pat (er-mk-nil) er-vm-l2-env))
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; ── OP_PATTERN_BINARY ───────────────────────────────────────────
|
||||||
|
;; Pattern <<A:8>> against <<42>> → A bound to 42
|
||||||
|
(define er-vm-b1-env (er-env-new))
|
||||||
|
(define er-vm-b1-pat {:type "binary" :segments (list {:value {:name "A" :type "var"} :size {:type "integer" :value "8"} :spec "integer"})})
|
||||||
|
(define er-vm-b1-val (er-mk-binary (list 42)))
|
||||||
|
(er-vm-test
|
||||||
|
"OP_PATTERN_BINARY match"
|
||||||
|
(er-vm-dispatch 130 (list er-vm-b1-pat er-vm-b1-val er-vm-b1-env))
|
||||||
|
true)
|
||||||
|
(er-vm-test
|
||||||
|
"OP_PATTERN_BINARY binds segment"
|
||||||
|
(get er-vm-b1-env "A")
|
||||||
|
42)
|
||||||
|
|
||||||
|
;; Same pattern against wrong-size binary (2 bytes) → false
|
||||||
|
(define er-vm-b2-env (er-env-new))
|
||||||
|
(define er-vm-b2-val (er-mk-binary (list 42 99)))
|
||||||
|
(er-vm-test
|
||||||
|
"OP_PATTERN_BINARY size mismatch"
|
||||||
|
(er-vm-dispatch 130 (list er-vm-b1-pat er-vm-b2-val er-vm-b2-env))
|
||||||
|
false)
|
||||||
|
|
||||||
|
;; ── dispatch error path ────────────────────────────────────────
|
||||||
|
(define er-vm-err-caught (list nil))
|
||||||
|
(guard
|
||||||
|
(c (:else (set-nth! er-vm-err-caught 0 (str c))))
|
||||||
|
(er-vm-dispatch 999 (list)))
|
||||||
|
(er-vm-test
|
||||||
|
"unknown opcode raises"
|
||||||
|
(string-contains? (str (nth er-vm-err-caught 0)) "unknown opcode")
|
||||||
|
true)
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 9c — OP_PERFORM / OP_HANDLE ───────────────────────────
|
||||||
|
(er-vm-test "perform opcode by id"
|
||||||
|
(get (er-vm-lookup-opcode-by-id 131) :name) "OP_PERFORM")
|
||||||
|
(er-vm-test "handle opcode by id"
|
||||||
|
(get (er-vm-lookup-opcode-by-id 132) :name) "OP_HANDLE")
|
||||||
|
|
||||||
|
(define er-vm-pf-caught (list nil))
|
||||||
|
(guard (c (:else (set-nth! er-vm-pf-caught 0 c)))
|
||||||
|
(er-vm-dispatch 131 (list "yield" (list 42))))
|
||||||
|
(er-vm-test "perform raises tagged"
|
||||||
|
(get (nth er-vm-pf-caught 0) :tag) "vm-effect")
|
||||||
|
(er-vm-test "perform effect name"
|
||||||
|
(get (nth er-vm-pf-caught 0) :effect) "yield")
|
||||||
|
(er-vm-test "perform args carried"
|
||||||
|
(nth (get (nth er-vm-pf-caught 0) :args) 0) 42)
|
||||||
|
|
||||||
|
(er-vm-test "handle catches matching effect"
|
||||||
|
(er-vm-dispatch 132
|
||||||
|
(list
|
||||||
|
(fn () (er-vm-dispatch 131 (list "yield" (list 7))))
|
||||||
|
"yield"
|
||||||
|
(fn (args) (+ (nth args 0) 100))))
|
||||||
|
107)
|
||||||
|
|
||||||
|
(er-vm-test "handle no-effect returns thunk result"
|
||||||
|
(er-vm-dispatch 132
|
||||||
|
(list
|
||||||
|
(fn () 99)
|
||||||
|
"yield"
|
||||||
|
(fn (args) "handler ran")))
|
||||||
|
99)
|
||||||
|
|
||||||
|
(define er-vm-rt-caught (list nil))
|
||||||
|
(guard (c (:else (set-nth! er-vm-rt-caught 0 c)))
|
||||||
|
(er-vm-dispatch 132
|
||||||
|
(list
|
||||||
|
(fn () (er-vm-dispatch 131 (list "other" (list))))
|
||||||
|
"yield"
|
||||||
|
(fn (args) "wrong"))))
|
||||||
|
(er-vm-test "handle rethrows non-matching"
|
||||||
|
(get (nth er-vm-rt-caught 0) :effect) "other")
|
||||||
|
|
||||||
|
(er-vm-test "nested handles separate effect names"
|
||||||
|
(er-vm-dispatch 132
|
||||||
|
(list
|
||||||
|
(fn ()
|
||||||
|
(er-vm-dispatch 132
|
||||||
|
(list
|
||||||
|
(fn () (er-vm-dispatch 131 (list "b" (list 5))))
|
||||||
|
"a"
|
||||||
|
(fn (args) "inner-handled"))))
|
||||||
|
"b"
|
||||||
|
(fn (args) (+ (nth args 0) 1000))))
|
||||||
|
1005)
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 9d — OP_RECEIVE_SCAN ──────────────────────────────────
|
||||||
|
(er-vm-test "receive-scan opcode by id"
|
||||||
|
(get (er-vm-lookup-opcode-by-id 133) :name) "OP_RECEIVE_SCAN")
|
||||||
|
|
||||||
|
;; Pattern: receive {ok, X} -> X end against mailbox [{error, 1}, {ok, 42}, foo]
|
||||||
|
(define er-vm-r1-env (er-env-new))
|
||||||
|
(define er-vm-r1-clauses
|
||||||
|
(list
|
||||||
|
{:pattern {:type "tuple"
|
||||||
|
:elements (list
|
||||||
|
{:type "atom" :value "ok"}
|
||||||
|
{:type "var" :name "X"})}
|
||||||
|
:guards (list)
|
||||||
|
:body (list {:type "var" :name "X"})}))
|
||||||
|
(define er-vm-r1-mbox
|
||||||
|
(list
|
||||||
|
(er-mk-tuple (list (er-mk-atom "error") 1))
|
||||||
|
(er-mk-tuple (list (er-mk-atom "ok") 42))
|
||||||
|
(er-mk-atom "foo")))
|
||||||
|
|
||||||
|
(define er-vm-r1-result
|
||||||
|
(er-vm-dispatch 133 (list er-vm-r1-clauses er-vm-r1-mbox er-vm-r1-env)))
|
||||||
|
(er-vm-test "scan finds match"
|
||||||
|
(get er-vm-r1-result :matched) true)
|
||||||
|
(er-vm-test "scan reports correct index"
|
||||||
|
(get er-vm-r1-result :index) 1)
|
||||||
|
(er-vm-test "scan binds var"
|
||||||
|
(get er-vm-r1-env "X") 42)
|
||||||
|
(er-vm-test "scan leaves body unevaluated"
|
||||||
|
(= (get er-vm-r1-result :body) nil) false)
|
||||||
|
|
||||||
|
;; No match case
|
||||||
|
(define er-vm-r2-env (er-env-new))
|
||||||
|
(define er-vm-r2-mbox (list (er-mk-atom "nope") 99))
|
||||||
|
(define er-vm-r2-result
|
||||||
|
(er-vm-dispatch 133 (list er-vm-r1-clauses er-vm-r2-mbox er-vm-r2-env)))
|
||||||
|
(er-vm-test "scan no-match"
|
||||||
|
(get er-vm-r2-result :matched) false)
|
||||||
|
(er-vm-test "scan no-match leaves env clean"
|
||||||
|
(dict-has? er-vm-r2-env "X") false)
|
||||||
|
|
||||||
|
;; Empty mailbox
|
||||||
|
(define er-vm-r3-result
|
||||||
|
(er-vm-dispatch 133 (list er-vm-r1-clauses (list) (er-env-new))))
|
||||||
|
(er-vm-test "scan empty mailbox"
|
||||||
|
(get er-vm-r3-result :matched) false)
|
||||||
|
|
||||||
|
;; First-match wins (arrival order)
|
||||||
|
(define er-vm-r4-env (er-env-new))
|
||||||
|
(define er-vm-r4-mbox
|
||||||
|
(list
|
||||||
|
(er-mk-tuple (list (er-mk-atom "ok") 1))
|
||||||
|
(er-mk-tuple (list (er-mk-atom "ok") 2))))
|
||||||
|
(define er-vm-r4-result
|
||||||
|
(er-vm-dispatch 133 (list er-vm-r1-clauses er-vm-r4-mbox er-vm-r4-env)))
|
||||||
|
(er-vm-test "scan first-match wins (index 0)"
|
||||||
|
(get er-vm-r4-result :index) 0)
|
||||||
|
(er-vm-test "scan binds first match's var"
|
||||||
|
(get er-vm-r4-env "X") 1)
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 9e — OP_SPAWN / OP_SEND ───────────────────────────────
|
||||||
|
(er-vm-procs-reset!)
|
||||||
|
|
||||||
|
(er-vm-test "spawn opcode by id"
|
||||||
|
(get (er-vm-lookup-opcode-by-id 134) :name) "OP_SPAWN")
|
||||||
|
(er-vm-test "send opcode by id"
|
||||||
|
(get (er-vm-lookup-opcode-by-id 135) :name) "OP_SEND")
|
||||||
|
|
||||||
|
(define er-vm-fn (fn () "body"))
|
||||||
|
(define er-vm-p1 (er-vm-dispatch 134 (list er-vm-fn (list))))
|
||||||
|
(define er-vm-p2 (er-vm-dispatch 134 (list er-vm-fn (list "arg"))))
|
||||||
|
(er-vm-test "spawn returns pid 0 first"
|
||||||
|
er-vm-p1 0)
|
||||||
|
(er-vm-test "spawn returns pid 1 second"
|
||||||
|
er-vm-p2 1)
|
||||||
|
(er-vm-test "proc count is 2"
|
||||||
|
(er-vm-proc-count) 2)
|
||||||
|
(er-vm-test "spawned proc state runnable"
|
||||||
|
(er-vm-proc-state er-vm-p1) "runnable")
|
||||||
|
(er-vm-test "spawned proc mailbox empty"
|
||||||
|
(len (er-vm-proc-mailbox er-vm-p1)) 0)
|
||||||
|
(er-vm-test "spawned proc has 8 registers"
|
||||||
|
(len (get (er-vm-proc-get er-vm-p1) :registers)) 8)
|
||||||
|
|
||||||
|
;; OP_SEND appends to target's mailbox, preserves arrival order.
|
||||||
|
(er-vm-test "send returns true on valid pid"
|
||||||
|
(er-vm-dispatch 135 (list er-vm-p1 "msg1")) true)
|
||||||
|
(er-vm-dispatch 135 (list er-vm-p1 "msg2")
|
||||||
|
)
|
||||||
|
(er-vm-dispatch 135 (list er-vm-p1 "msg3"))
|
||||||
|
(er-vm-test "mailbox length after 3 sends"
|
||||||
|
(len (er-vm-proc-mailbox er-vm-p1)) 3)
|
||||||
|
(er-vm-test "mailbox preserves order — first"
|
||||||
|
(nth (er-vm-proc-mailbox er-vm-p1) 0) "msg1")
|
||||||
|
(er-vm-test "mailbox preserves order — last"
|
||||||
|
(nth (er-vm-proc-mailbox er-vm-p1) 2) "msg3")
|
||||||
|
|
||||||
|
;; send to nonexistent pid returns false (doesn't crash)
|
||||||
|
(er-vm-test "send to unknown pid is false"
|
||||||
|
(er-vm-dispatch 135 (list 99999 "x")) false)
|
||||||
|
|
||||||
|
;; Isolation: msgs to p1 don't appear in p2's mailbox
|
||||||
|
(er-vm-test "isolation — p2 mailbox empty"
|
||||||
|
(len (er-vm-proc-mailbox er-vm-p2)) 0)
|
||||||
|
|
||||||
|
;; reset clears
|
||||||
|
(er-vm-procs-reset!)
|
||||||
|
(er-vm-test "reset clears procs"
|
||||||
|
(er-vm-proc-count) 0)
|
||||||
|
(er-vm-test "reset resets pid counter"
|
||||||
|
(er-vm-dispatch 134 (list er-vm-fn (list))) 0)
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 9f — hot-BIF dispatch table ───────────────────────────
|
||||||
|
;; Each opcode skips the registry lookup and calls the underlying
|
||||||
|
;; er-bif-* directly. Verify each returns the same result as going
|
||||||
|
;; through er-apply-bif.
|
||||||
|
|
||||||
|
(er-vm-test "BIF_LENGTH opcode by id"
|
||||||
|
(get (er-vm-lookup-opcode-by-id 136) :name) "OP_BIF_LENGTH")
|
||||||
|
(er-vm-test "BIF_LENGTH on 3-cons"
|
||||||
|
(er-vm-dispatch 136
|
||||||
|
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))))
|
||||||
|
3)
|
||||||
|
|
||||||
|
(er-vm-test "BIF_HD on cons"
|
||||||
|
(er-vm-dispatch 137 (list (er-mk-cons 99 (er-mk-nil)))) 99)
|
||||||
|
|
||||||
|
(er-vm-test "BIF_TL is cons"
|
||||||
|
(er-cons? (er-vm-dispatch 138
|
||||||
|
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-nil)))))) true)
|
||||||
|
|
||||||
|
(er-vm-test "BIF_ELEMENT pulls index"
|
||||||
|
(er-vm-dispatch 139 (list 2 (er-mk-tuple (list "a" "b" "c")))) "b")
|
||||||
|
|
||||||
|
(er-vm-test "BIF_TUPLE_SIZE on 4-tuple"
|
||||||
|
(er-vm-dispatch 140 (list (er-mk-tuple (list 1 2 3 4)))) 4)
|
||||||
|
|
||||||
|
(er-vm-test "BIF_LISTS_REVERSE preserves elements"
|
||||||
|
(er-list-length (er-vm-dispatch 141
|
||||||
|
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil))))))) 3)
|
||||||
|
|
||||||
|
(er-vm-test "BIF_LISTS_REVERSE actually reverses"
|
||||||
|
(get (er-vm-dispatch 141
|
||||||
|
(list (er-mk-cons 1 (er-mk-cons 2 (er-mk-cons 3 (er-mk-nil)))))) :head) 3)
|
||||||
|
|
||||||
|
(er-vm-test "BIF_IS_INTEGER true on int"
|
||||||
|
(get (er-vm-dispatch 142 (list 42)) :name) "true")
|
||||||
|
(er-vm-test "BIF_IS_INTEGER false on float"
|
||||||
|
(get (er-vm-dispatch 142 (list 3.14)) :name) "false")
|
||||||
|
|
||||||
|
(er-vm-test "BIF_IS_ATOM true"
|
||||||
|
(get (er-vm-dispatch 143 (list (er-mk-atom "ok"))) :name) "true")
|
||||||
|
(er-vm-test "BIF_IS_ATOM false on int"
|
||||||
|
(get (er-vm-dispatch 143 (list 7)) :name) "false")
|
||||||
|
|
||||||
|
(er-vm-test "BIF_IS_LIST true on cons"
|
||||||
|
(get (er-vm-dispatch 144
|
||||||
|
(list (er-mk-cons 1 (er-mk-nil)))) :name) "true")
|
||||||
|
(er-vm-test "BIF_IS_LIST true on nil"
|
||||||
|
(get (er-vm-dispatch 144 (list (er-mk-nil))) :name) "true")
|
||||||
|
(er-vm-test "BIF_IS_LIST false on tuple"
|
||||||
|
(get (er-vm-dispatch 144 (list (er-mk-tuple (list)))) :name) "false")
|
||||||
|
|
||||||
|
(er-vm-test "BIF_IS_TUPLE true"
|
||||||
|
(get (er-vm-dispatch 145 (list (er-mk-tuple (list 1)))) :name) "true")
|
||||||
|
(er-vm-test "BIF_IS_TUPLE false on int"
|
||||||
|
(get (er-vm-dispatch 145 (list 5)) :name) "false")
|
||||||
|
|
||||||
|
;; Sanity: total opcode count grew (3 patterns + perform + handle +
|
||||||
|
;; receive-scan + spawn + send + 10 hot-BIFs = 16+ registered).
|
||||||
|
(er-vm-test "opcode list has 16+"
|
||||||
|
(>= (len (er-vm-list-opcodes)) 16) true)
|
||||||
|
|
||||||
|
|
||||||
|
;; ── Phase 9i — host opcode-id resolution ────────────────────────
|
||||||
|
;; Requires a binary with the erlang_ext extension registered (9h).
|
||||||
|
;; The loop runs conformance against exactly that binary.
|
||||||
|
(er-vm-test "host id: OP_PATTERN_TUPLE = 222"
|
||||||
|
(er-vm-host-opcode-id "erlang.OP_PATTERN_TUPLE") 222)
|
||||||
|
(er-vm-test "host id: OP_BIF_IS_TUPLE = 239"
|
||||||
|
(er-vm-host-opcode-id "erlang.OP_BIF_IS_TUPLE") 239)
|
||||||
|
(er-vm-test "host id: unknown name -> nil"
|
||||||
|
(er-vm-host-opcode-id "erlang.OP_NOPE") nil)
|
||||||
|
(er-vm-test "effective id prefers host when present"
|
||||||
|
(er-vm-effective-opcode-id "erlang.OP_BIF_LENGTH" 136) 230)
|
||||||
|
(er-vm-test "effective id falls back to stub on nil"
|
||||||
|
(er-vm-effective-opcode-id "erlang.OP_NOPE" 999) 999)
|
||||||
|
;; The full erlang.OP_* namespace resolves to the contiguous 222-239 block.
|
||||||
|
(er-vm-test "host ids contiguous 222..239"
|
||||||
|
(let ((names (list "erlang.OP_PATTERN_TUPLE" "erlang.OP_PATTERN_LIST"
|
||||||
|
"erlang.OP_PATTERN_BINARY" "erlang.OP_PERFORM"
|
||||||
|
"erlang.OP_HANDLE" "erlang.OP_RECEIVE_SCAN"
|
||||||
|
"erlang.OP_SPAWN" "erlang.OP_SEND"
|
||||||
|
"erlang.OP_BIF_LENGTH" "erlang.OP_BIF_HD"
|
||||||
|
"erlang.OP_BIF_TL" "erlang.OP_BIF_ELEMENT"
|
||||||
|
"erlang.OP_BIF_TUPLE_SIZE" "erlang.OP_BIF_LISTS_REVERSE"
|
||||||
|
"erlang.OP_BIF_IS_INTEGER" "erlang.OP_BIF_IS_ATOM"
|
||||||
|
"erlang.OP_BIF_IS_LIST" "erlang.OP_BIF_IS_TUPLE"))
|
||||||
|
(ok (list true)))
|
||||||
|
(for-each
|
||||||
|
(fn (i)
|
||||||
|
(when (not (= (er-vm-host-opcode-id (nth names i)) (+ 222 i)))
|
||||||
|
(set-nth! ok 0 false)))
|
||||||
|
(range 0 (len names)))
|
||||||
|
(nth ok 0))
|
||||||
|
true)
|
||||||
|
|
||||||
|
(define er-vm-test-summary (str "vm " er-vm-test-pass "/" er-vm-test-count))
|
||||||
@@ -229,13 +229,37 @@
|
|||||||
(= ch "$")
|
(= ch "$")
|
||||||
(do
|
(do
|
||||||
(er-advance! 1)
|
(er-advance! 1)
|
||||||
(if
|
;; Emit the char's decimal code as the integer token value
|
||||||
(and (< pos src-len) (= (er-cur) "\\"))
|
;; (was: raw "$X" text — parse-number then returned nil).
|
||||||
(do
|
(let
|
||||||
(er-advance! 1)
|
((code (cond
|
||||||
(when (< pos src-len) (er-advance! 1)))
|
(>= pos src-len) 0
|
||||||
(when (< pos src-len) (er-advance! 1)))
|
(= (er-cur) "\\")
|
||||||
(er-emit! "integer" (slice src start pos) start)
|
(do
|
||||||
|
(er-advance! 1)
|
||||||
|
(let ((esc (if (< pos src-len) (er-cur) "")))
|
||||||
|
(when (< pos src-len) (er-advance! 1))
|
||||||
|
(cond
|
||||||
|
(= esc "n") 10
|
||||||
|
(= esc "t") 9
|
||||||
|
(= esc "r") 13
|
||||||
|
(= esc "s") 32
|
||||||
|
(= esc "b") 8
|
||||||
|
(= esc "e") 27
|
||||||
|
(= esc "f") 12
|
||||||
|
(= esc "v") 11
|
||||||
|
(= esc "d") 127
|
||||||
|
(= esc "0") 0
|
||||||
|
(= esc "\\") 92
|
||||||
|
(= esc "\"") 34
|
||||||
|
(= esc "'") 39
|
||||||
|
(= esc "") 0
|
||||||
|
:else (char->integer (nth (string->list esc) 0)))))
|
||||||
|
:else
|
||||||
|
(let ((c (er-cur)))
|
||||||
|
(er-advance! 1)
|
||||||
|
(char->integer (nth (string->list c) 0))))))
|
||||||
|
(er-emit! "integer" (str code) start))
|
||||||
(scan!))
|
(scan!))
|
||||||
(er-lower? ch)
|
(er-lower? ch)
|
||||||
(do
|
(do
|
||||||
|
|||||||
@@ -107,7 +107,12 @@
|
|||||||
(let
|
(let
|
||||||
((ty (get node :type)))
|
((ty (get node :type)))
|
||||||
(cond
|
(cond
|
||||||
(= ty "integer") (parse-number (get node :value))
|
(= ty "integer")
|
||||||
|
(let ((n (parse-number (get node :value))))
|
||||||
|
(cond
|
||||||
|
(= n nil) (error (str "Erlang: invalid integer literal: "
|
||||||
|
(get node :value)))
|
||||||
|
:else (truncate n)))
|
||||||
(= ty "float") (parse-number (get node :value))
|
(= ty "float") (parse-number (get node :value))
|
||||||
(= ty "atom") (er-mk-atom (get node :value))
|
(= ty "atom") (er-mk-atom (get node :value))
|
||||||
(= ty "string") (get node :value)
|
(= ty "string") (get node :value)
|
||||||
@@ -669,96 +674,23 @@
|
|||||||
|
|
||||||
(define
|
(define
|
||||||
er-apply-bif
|
er-apply-bif
|
||||||
(fn
|
(fn (name vs)
|
||||||
(name vs)
|
(let ((entry (er-lookup-bif "erlang" name (len vs))))
|
||||||
(cond
|
(if (not (= entry nil))
|
||||||
(= name "is_integer") (er-bif-is-integer vs)
|
((get entry :fn) vs)
|
||||||
(= name "is_atom") (er-bif-is-atom vs)
|
(error (str "Erlang: undefined function '" name "/" (len vs) "'"))))))
|
||||||
(= name "is_list") (er-bif-is-list vs)
|
|
||||||
(= name "is_tuple") (er-bif-is-tuple vs)
|
|
||||||
(= name "is_number") (er-bif-is-number vs)
|
|
||||||
(= name "is_float") (er-bif-is-float vs)
|
|
||||||
(= name "is_boolean") (er-bif-is-boolean vs)
|
|
||||||
(= name "length") (er-bif-length vs)
|
|
||||||
(= name "hd") (er-bif-hd vs)
|
|
||||||
(= name "tl") (er-bif-tl vs)
|
|
||||||
(= name "element") (er-bif-element vs)
|
|
||||||
(= name "tuple_size") (er-bif-tuple-size vs)
|
|
||||||
(= name "atom_to_list") (er-bif-atom-to-list vs)
|
|
||||||
(= name "list_to_atom") (er-bif-list-to-atom vs)
|
|
||||||
(= name "is_pid") (er-bif-is-pid vs)
|
|
||||||
(= name "is_reference") (er-bif-is-reference vs)
|
|
||||||
(= name "is_binary") (er-bif-is-binary vs)
|
|
||||||
(= name "byte_size") (er-bif-byte-size vs)
|
|
||||||
(= name "abs") (er-bif-abs vs)
|
|
||||||
(= name "min") (er-bif-min vs)
|
|
||||||
(= name "max") (er-bif-max vs)
|
|
||||||
(= name "tuple_to_list") (er-bif-tuple-to-list vs)
|
|
||||||
(= name "list_to_tuple") (er-bif-list-to-tuple vs)
|
|
||||||
(= name "integer_to_list") (er-bif-integer-to-list vs)
|
|
||||||
(= name "list_to_integer") (er-bif-list-to-integer vs)
|
|
||||||
(= name "is_function") (er-bif-is-function vs)
|
|
||||||
(= name "self") (er-bif-self vs)
|
|
||||||
(= name "spawn") (er-bif-spawn vs)
|
|
||||||
(= name "exit") (er-bif-exit vs)
|
|
||||||
(= name "make_ref") (er-bif-make-ref vs)
|
|
||||||
(= name "link") (er-bif-link vs)
|
|
||||||
(= name "unlink") (er-bif-unlink vs)
|
|
||||||
(= name "monitor") (er-bif-monitor vs)
|
|
||||||
(= name "demonitor") (er-bif-demonitor vs)
|
|
||||||
(= name "process_flag") (er-bif-process-flag vs)
|
|
||||||
(= name "register") (er-bif-register vs)
|
|
||||||
(= name "unregister") (er-bif-unregister vs)
|
|
||||||
(= name "whereis") (er-bif-whereis vs)
|
|
||||||
(= name "registered") (er-bif-registered vs)
|
|
||||||
(= name "throw") (raise (er-mk-throw-marker (er-bif-arg1 vs "throw")))
|
|
||||||
(= name "error") (raise (er-mk-error-marker (er-bif-arg1 vs "error")))
|
|
||||||
:else (error
|
|
||||||
(str "Erlang: undefined function '" name "/" (len vs) "'")))))
|
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-apply-remote-bif
|
er-apply-remote-bif
|
||||||
(fn
|
(fn (mod name vs)
|
||||||
(mod name vs)
|
|
||||||
(cond
|
(cond
|
||||||
(dict-has? (er-modules-get) mod)
|
(dict-has? (er-modules-get) mod)
|
||||||
(er-apply-user-module mod name vs)
|
(er-apply-user-module mod name vs)
|
||||||
(= mod "lists") (er-apply-lists-bif name vs)
|
:else
|
||||||
(= mod "io") (er-apply-io-bif name vs)
|
(let ((entry (er-lookup-bif mod name (len vs))))
|
||||||
(= mod "erlang") (er-apply-bif name vs)
|
(if (not (= entry nil))
|
||||||
(= mod "ets") (er-apply-ets-bif name vs)
|
((get entry :fn) vs)
|
||||||
:else (error
|
(error (str "Erlang: undefined remote function '" mod ":" name "/" (len vs) "'")))))))
|
||||||
(str "Erlang: undefined module '" mod "'")))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
er-apply-lists-bif
|
|
||||||
(fn
|
|
||||||
(name vs)
|
|
||||||
(cond
|
|
||||||
(= name "reverse") (er-bif-lists-reverse vs)
|
|
||||||
(= name "map") (er-bif-lists-map vs)
|
|
||||||
(= name "foldl") (er-bif-lists-foldl vs)
|
|
||||||
(= name "seq") (er-bif-lists-seq vs)
|
|
||||||
(= name "sum") (er-bif-lists-sum vs)
|
|
||||||
(= name "nth") (er-bif-lists-nth vs)
|
|
||||||
(= name "last") (er-bif-lists-last vs)
|
|
||||||
(= name "member") (er-bif-lists-member vs)
|
|
||||||
(= name "append") (er-bif-lists-append vs)
|
|
||||||
(= name "filter") (er-bif-lists-filter vs)
|
|
||||||
(= name "any") (er-bif-lists-any vs)
|
|
||||||
(= name "all") (er-bif-lists-all vs)
|
|
||||||
(= name "duplicate") (er-bif-lists-duplicate vs)
|
|
||||||
:else (error
|
|
||||||
(str "Erlang: undefined 'lists:" name "/" (len vs) "'")))))
|
|
||||||
|
|
||||||
(define
|
|
||||||
er-apply-io-bif
|
|
||||||
(fn
|
|
||||||
(name vs)
|
|
||||||
(cond
|
|
||||||
(= name "format") (er-bif-io-format vs)
|
|
||||||
:else (error
|
|
||||||
(str "Erlang: undefined 'io:" name "/" (len vs) "'")))))
|
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-bif-arg1
|
er-bif-arg1
|
||||||
@@ -894,16 +826,30 @@
|
|||||||
(len (get v :elements))
|
(len (get v :elements))
|
||||||
(error "Erlang: tuple_size: not a tuple")))))
|
(error "Erlang: tuple_size: not a tuple")))))
|
||||||
|
|
||||||
|
(define er-string->charlist
|
||||||
|
(fn (s)
|
||||||
|
(let ((cs (string->list s)) (out (er-mk-nil)))
|
||||||
|
(for-each
|
||||||
|
(fn (i)
|
||||||
|
(set! out (er-mk-cons
|
||||||
|
(char->integer (nth cs (- (- (len cs) 1) i)))
|
||||||
|
out)))
|
||||||
|
(range 0 (len cs)))
|
||||||
|
out)))
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-bif-atom-to-list
|
er-bif-atom-to-list
|
||||||
(fn
|
(fn
|
||||||
(vs)
|
(vs)
|
||||||
(let
|
(let
|
||||||
((v (er-bif-arg1 vs "atom_to_list")))
|
((v (er-bif-arg1 vs "atom_to_list")))
|
||||||
|
;; Standard Erlang: atom_to_list/1 returns an Erlang charlist
|
||||||
|
;; (list of integer char codes). Was: SX string of :name —
|
||||||
|
;; unusable from Erlang-land for [Char|T] / ++ / binary segments.
|
||||||
(if
|
(if
|
||||||
(er-atom? v)
|
(er-atom? v)
|
||||||
(get v :name)
|
(er-string->charlist (get v :name))
|
||||||
(error "Erlang: atom_to_list: not an atom")))))
|
(raise (er-mk-error-marker (er-mk-atom "badarg")))))))
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-bif-list-to-atom
|
er-bif-list-to-atom
|
||||||
@@ -911,10 +857,11 @@
|
|||||||
(vs)
|
(vs)
|
||||||
(let
|
(let
|
||||||
((v (er-bif-arg1 vs "list_to_atom")))
|
((v (er-bif-arg1 vs "list_to_atom")))
|
||||||
(if
|
;; Accept Erlang charlist (cons of ints) or SX string.
|
||||||
(= (type-of v) "string")
|
(let ((s (er-source-to-string v)))
|
||||||
(er-mk-atom v)
|
(cond
|
||||||
(error "Erlang: list_to_atom: not a string")))))
|
(= s nil) (raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
|
:else (er-mk-atom s))))))
|
||||||
|
|
||||||
;; ── lists module ─────────────────────────────────────────────────
|
;; ── lists module ─────────────────────────────────────────────────
|
||||||
(define
|
(define
|
||||||
@@ -1200,7 +1147,7 @@
|
|||||||
(and (er-atom? ms) (= (get ms :name) "infinity"))
|
(and (er-atom? ms) (= (get ms :name) "infinity"))
|
||||||
(er-eval-receive-loop node pid env)
|
(er-eval-receive-loop node pid env)
|
||||||
(= ms 0) (er-eval-receive-poll node pid env)
|
(= ms 0) (er-eval-receive-poll node pid env)
|
||||||
:else (er-eval-receive-timed node pid env)))))
|
:else (er-eval-receive-timed node pid env (+ (er-clock) ms))))))
|
||||||
|
|
||||||
;; after 0 — poll once; on no match, run the after-body immediately.
|
;; after 0 — poll once; on no match, run the after-body immediately.
|
||||||
(define
|
(define
|
||||||
@@ -1214,12 +1161,15 @@
|
|||||||
(get r :value)
|
(get r :value)
|
||||||
(er-eval-body (get node :after-body) env)))))
|
(er-eval-body (get node :after-body) env)))))
|
||||||
|
|
||||||
;; after Ms — suspend; on resume check :timed-out. When the scheduler
|
;; after Ms — suspend with an absolute `deadline` (logical ms). On
|
||||||
;; runs out of other work it fires one pending timeout per round.
|
;; resume check :timed-out: the scheduler fires the earliest pending
|
||||||
|
;; deadline once the runnable queue drains. A non-matching message can
|
||||||
|
;; wake the process early; it re-suspends on the SAME deadline so the
|
||||||
|
;; timeout window is not extended.
|
||||||
(define
|
(define
|
||||||
er-eval-receive-timed
|
er-eval-receive-timed
|
||||||
(fn
|
(fn
|
||||||
(node pid env)
|
(node pid env deadline)
|
||||||
(let
|
(let
|
||||||
((r (er-try-receive (get node :clauses) pid env)))
|
((r (er-try-receive (get node :clauses) pid env)))
|
||||||
(if
|
(if
|
||||||
@@ -1227,6 +1177,7 @@
|
|||||||
(get r :value)
|
(get r :value)
|
||||||
(do
|
(do
|
||||||
(er-proc-set! pid :has-timeout true)
|
(er-proc-set! pid :has-timeout true)
|
||||||
|
(er-proc-set! pid :timeout-deadline deadline)
|
||||||
(call/cc
|
(call/cc
|
||||||
(fn
|
(fn
|
||||||
(k)
|
(k)
|
||||||
@@ -1239,7 +1190,7 @@
|
|||||||
(er-proc-set! pid :timed-out false)
|
(er-proc-set! pid :timed-out false)
|
||||||
(er-proc-set! pid :has-timeout false)
|
(er-proc-set! pid :has-timeout false)
|
||||||
(er-eval-body (get node :after-body) env))
|
(er-eval-body (get node :after-body) env))
|
||||||
(er-eval-receive-timed node pid env)))))))
|
(er-eval-receive-timed node pid env deadline)))))))
|
||||||
|
|
||||||
;; Scan mailbox in arrival order. For each msg, try every clause.
|
;; Scan mailbox in arrival order. For each msg, try every clause.
|
||||||
;; On first match: remove that msg from mailbox and return body value.
|
;; On first match: remove that msg from mailbox and return body value.
|
||||||
@@ -1670,10 +1621,12 @@
|
|||||||
(vs)
|
(vs)
|
||||||
(let
|
(let
|
||||||
((v (er-bif-arg1 vs "integer_to_list")))
|
((v (er-bif-arg1 vs "integer_to_list")))
|
||||||
|
;; Standard Erlang: integer_to_list/1 returns an Erlang charlist
|
||||||
|
;; (e.g. integer_to_list(42) -> [$4, $2] -> [52, 50]).
|
||||||
(cond
|
(cond
|
||||||
(not (= (type-of v) "number"))
|
(not (= (type-of v) "number"))
|
||||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
:else (str v)))))
|
:else (er-string->charlist (str v))))))
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-bif-list-to-integer
|
er-bif-list-to-integer
|
||||||
@@ -1681,15 +1634,14 @@
|
|||||||
(vs)
|
(vs)
|
||||||
(let
|
(let
|
||||||
((v (er-bif-arg1 vs "list_to_integer")))
|
((v (er-bif-arg1 vs "list_to_integer")))
|
||||||
(cond
|
;; Accept Erlang charlist (cons of ints) or SX string.
|
||||||
(not (= (type-of v) "string"))
|
(let ((s (er-source-to-string v)))
|
||||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
(cond
|
||||||
:else (let
|
(= s nil) (raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
((n (parse-number v)))
|
:else (let ((n (parse-number s)))
|
||||||
(cond
|
(cond
|
||||||
(= n nil)
|
(= n nil) (raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
:else n)))))))
|
||||||
:else n))))))
|
|
||||||
|
|
||||||
(define
|
(define
|
||||||
er-bif-is-function
|
er-bif-is-function
|
||||||
@@ -1911,3 +1863,180 @@
|
|||||||
(fn (_) (set! out (er-mk-cons v out)))
|
(fn (_) (set! out (er-mk-cons v out)))
|
||||||
(range 0 n))
|
(range 0 n))
|
||||||
out))))
|
out))))
|
||||||
|
|
||||||
|
|
||||||
|
;; ── code module (Phase 7 hot-reload) ─────────────────────────────
|
||||||
|
(define er-source-walk-bytes!
|
||||||
|
(fn (n bytes-box)
|
||||||
|
(cond
|
||||||
|
(er-nil? n) true
|
||||||
|
(er-cons? n)
|
||||||
|
(let ((h (get n :head)))
|
||||||
|
(cond
|
||||||
|
(= (type-of h) "number")
|
||||||
|
(do (append! (nth bytes-box 0) h)
|
||||||
|
(er-source-walk-bytes! (get n :tail) bytes-box))
|
||||||
|
:else (do (set-nth! bytes-box 0 nil) false)))
|
||||||
|
:else (do (set-nth! bytes-box 0 nil) false))))
|
||||||
|
|
||||||
|
(define er-source-to-string
|
||||||
|
(fn (v)
|
||||||
|
(cond
|
||||||
|
(= (type-of v) "string") v
|
||||||
|
(er-binary? v) (list->string (map integer->char (get v :bytes)))
|
||||||
|
(or (er-nil? v) (er-cons? v))
|
||||||
|
(let ((box (list (list))))
|
||||||
|
(er-source-walk-bytes! v box)
|
||||||
|
(cond
|
||||||
|
(= (nth box 0) nil) nil
|
||||||
|
:else (list->string (map integer->char (nth box 0)))))
|
||||||
|
:else nil)))
|
||||||
|
|
||||||
|
(define er-bif-code-load-binary
|
||||||
|
(fn (vs)
|
||||||
|
(let ((mod-arg (nth vs 0)) (src-arg (nth vs 2)))
|
||||||
|
(cond
|
||||||
|
(not (er-atom? mod-arg))
|
||||||
|
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
|
||||||
|
:else
|
||||||
|
(let ((src-str (er-source-to-string src-arg)))
|
||||||
|
(cond
|
||||||
|
(= src-str nil)
|
||||||
|
(er-mk-tuple (list (er-mk-atom "error") (er-mk-atom "badarg")))
|
||||||
|
:else
|
||||||
|
(let ((result-box (list nil)) (failed-box (list false)))
|
||||||
|
(guard
|
||||||
|
(c (:else (set-nth! failed-box 0 true)))
|
||||||
|
(set-nth! result-box 0 (erlang-load-module src-str)))
|
||||||
|
(cond
|
||||||
|
(nth failed-box 0)
|
||||||
|
(er-mk-tuple
|
||||||
|
(list (er-mk-atom "error") (er-mk-atom "badfile")))
|
||||||
|
(not (= (get (nth result-box 0) :name) (get mod-arg :name)))
|
||||||
|
(er-mk-tuple
|
||||||
|
(list (er-mk-atom "error") (er-mk-atom "module_name_mismatch")))
|
||||||
|
:else
|
||||||
|
(er-mk-tuple (list (er-mk-atom "module") mod-arg))))))))))
|
||||||
|
|
||||||
|
(define er-env-derived-from?
|
||||||
|
(fn (env target-env)
|
||||||
|
;; Object-identity check, NOT value `=`. On evaluators where dict `=`
|
||||||
|
;; is structural/deep, comparing closure envs (which are large and
|
||||||
|
;; cyclic — a module fun's env references the fun) does not terminate.
|
||||||
|
;; `identical?` is pointer identity on every host and is the actual
|
||||||
|
;; intended semantics: "is this the same env object".
|
||||||
|
(cond
|
||||||
|
(identical? env target-env) true
|
||||||
|
:else
|
||||||
|
(let ((ks (keys env)) (found-ref (list false)))
|
||||||
|
(for-each
|
||||||
|
(fn (i)
|
||||||
|
(when (not (nth found-ref 0))
|
||||||
|
(let ((v (get env (nth ks i))))
|
||||||
|
(when (and (er-fun? v) (identical? (get v :env) target-env))
|
||||||
|
(set-nth! found-ref 0 true)))))
|
||||||
|
(range 0 (len ks)))
|
||||||
|
(nth found-ref 0)))))
|
||||||
|
|
||||||
|
(define er-procs-on-env
|
||||||
|
(fn (target-env)
|
||||||
|
(let ((all-keys (keys (er-sched-processes)))
|
||||||
|
(matches (list)))
|
||||||
|
(for-each
|
||||||
|
(fn (i)
|
||||||
|
(let ((proc (get (er-sched-processes) (nth all-keys i))))
|
||||||
|
(let ((init-fun (get proc :initial-fun)))
|
||||||
|
(when (and (not (= init-fun nil))
|
||||||
|
(er-fun? init-fun)
|
||||||
|
(er-env-derived-from? (get init-fun :env) target-env)
|
||||||
|
(not (= (get proc :state) "dead")))
|
||||||
|
(append! matches (get proc :pid))))))
|
||||||
|
(range 0 (len all-keys)))
|
||||||
|
matches)))
|
||||||
|
|
||||||
|
(define er-bif-code-purge
|
||||||
|
(fn (vs)
|
||||||
|
(let ((mod-arg (nth vs 0)))
|
||||||
|
(cond
|
||||||
|
(not (er-atom? mod-arg))
|
||||||
|
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
|
:else
|
||||||
|
(let ((registry (er-modules-get)) (mod-name (get mod-arg :name)))
|
||||||
|
(cond
|
||||||
|
(not (dict-has? registry mod-name)) (er-mk-atom "false")
|
||||||
|
:else
|
||||||
|
(let ((slot (get registry mod-name)))
|
||||||
|
(cond
|
||||||
|
(= (er-module-old-env slot) nil) (er-mk-atom "false")
|
||||||
|
:else
|
||||||
|
(let ((procs (er-procs-on-env (er-module-old-env slot))))
|
||||||
|
(for-each
|
||||||
|
(fn (i) (er-cascade-exit! (nth procs i) (er-mk-atom "killed")))
|
||||||
|
(range 0 (len procs)))
|
||||||
|
(dict-set! registry mod-name
|
||||||
|
(er-mk-module-slot (er-module-current-env slot) nil
|
||||||
|
(er-module-version slot)))
|
||||||
|
(er-mk-atom "true"))))))))))
|
||||||
|
|
||||||
|
(define er-bif-code-soft-purge
|
||||||
|
(fn (vs)
|
||||||
|
(let ((mod-arg (nth vs 0)))
|
||||||
|
(cond
|
||||||
|
(not (er-atom? mod-arg))
|
||||||
|
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
|
:else
|
||||||
|
(let ((registry (er-modules-get)) (mod-name (get mod-arg :name)))
|
||||||
|
(cond
|
||||||
|
(not (dict-has? registry mod-name)) (er-mk-atom "true")
|
||||||
|
:else
|
||||||
|
(let ((slot (get registry mod-name)))
|
||||||
|
(cond
|
||||||
|
(= (er-module-old-env slot) nil) (er-mk-atom "true")
|
||||||
|
:else
|
||||||
|
(let ((procs (er-procs-on-env (er-module-old-env slot))))
|
||||||
|
(cond
|
||||||
|
(> (len procs) 0) (er-mk-atom "false")
|
||||||
|
:else
|
||||||
|
(do
|
||||||
|
(dict-set! registry mod-name
|
||||||
|
(er-mk-module-slot (er-module-current-env slot) nil
|
||||||
|
(er-module-version slot)))
|
||||||
|
(er-mk-atom "true"))))))))))))
|
||||||
|
|
||||||
|
(define er-bif-code-which
|
||||||
|
(fn (vs)
|
||||||
|
(let ((mod-arg (nth vs 0)))
|
||||||
|
(cond
|
||||||
|
(not (er-atom? mod-arg))
|
||||||
|
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
|
(dict-has? (er-modules-get) (get mod-arg :name))
|
||||||
|
(er-mk-atom "loaded")
|
||||||
|
:else (er-mk-atom "non_existing")))))
|
||||||
|
|
||||||
|
(define er-bif-code-is-loaded
|
||||||
|
(fn (vs)
|
||||||
|
(let ((mod-arg (nth vs 0)))
|
||||||
|
(cond
|
||||||
|
(not (er-atom? mod-arg))
|
||||||
|
(raise (er-mk-error-marker (er-mk-atom "badarg")))
|
||||||
|
(dict-has? (er-modules-get) (get mod-arg :name))
|
||||||
|
(er-mk-tuple (list (er-mk-atom "file") (er-mk-atom "loaded")))
|
||||||
|
:else (er-mk-atom "false")))))
|
||||||
|
|
||||||
|
(define er-bif-code-all-loaded
|
||||||
|
(fn (vs)
|
||||||
|
(let ((registry (er-modules-get))
|
||||||
|
(ks (keys (er-modules-get)))
|
||||||
|
(out (er-mk-nil)))
|
||||||
|
(for-each
|
||||||
|
(fn (i)
|
||||||
|
(let ((k (nth ks (- (- (len ks) 1) i))))
|
||||||
|
(set! out
|
||||||
|
(er-mk-cons
|
||||||
|
(er-mk-tuple
|
||||||
|
(list (er-mk-atom k) (er-mk-atom "loaded")))
|
||||||
|
out))))
|
||||||
|
(range 0 (len ks)))
|
||||||
|
out)))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
313
lib/erlang/vm/dispatcher.sx
Normal file
313
lib/erlang/vm/dispatcher.sx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
;; Erlang VM — stub opcode dispatcher (Phase 9).
|
||||||
|
;;
|
||||||
|
;; Mimics the OCaml-side EXTENSION shape from
|
||||||
|
;; plans/sx-vm-opcode-extension.md so opcodes 9b-9g can be designed
|
||||||
|
;; and tested in SX before 9a (`hosts/ocaml/`) lands the real
|
||||||
|
;; registration plumbing. When 9a is available, these stubs become
|
||||||
|
;; the cross-host SX-side mirror of the C/OCaml handlers and the
|
||||||
|
;; bytecode compiler emits them directly.
|
||||||
|
;;
|
||||||
|
;; Opcode IDs follow the plan's tier partition:
|
||||||
|
;; 0-127 reserved for SX core
|
||||||
|
;; 128-199 guest extensions (e.g. erlang, lua)
|
||||||
|
;; 200-247 port-/platform-specific
|
||||||
|
;;
|
||||||
|
;; Erlang owns 128-159 for now.
|
||||||
|
|
||||||
|
(define er-vm-opcodes (list {}))
|
||||||
|
|
||||||
|
(define er-vm-opcodes-get (fn () (nth er-vm-opcodes 0)))
|
||||||
|
|
||||||
|
(define
|
||||||
|
er-vm-opcodes-reset!
|
||||||
|
(fn () (set-nth! er-vm-opcodes 0 {})))
|
||||||
|
|
||||||
|
(define
|
||||||
|
er-vm-register-opcode!
|
||||||
|
(fn
|
||||||
|
(id name handler)
|
||||||
|
(dict-set! (er-vm-opcodes-get) (str id) {:name name :id id :handler handler})
|
||||||
|
(er-mk-atom "ok")))
|
||||||
|
|
||||||
|
(define
|
||||||
|
er-vm-lookup-opcode-by-id
|
||||||
|
(fn
|
||||||
|
(id)
|
||||||
|
(let
|
||||||
|
((reg (er-vm-opcodes-get)) (k (str id)))
|
||||||
|
(if (dict-has? reg k) (get reg k) nil))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
er-vm-lookup-opcode-by-name
|
||||||
|
(fn
|
||||||
|
(name)
|
||||||
|
(let
|
||||||
|
((reg (er-vm-opcodes-get))
|
||||||
|
(ks (keys (er-vm-opcodes-get)))
|
||||||
|
(found (list nil)))
|
||||||
|
(for-each
|
||||||
|
(fn
|
||||||
|
(i)
|
||||||
|
(let
|
||||||
|
((entry (get reg (nth ks i))))
|
||||||
|
(when
|
||||||
|
(= (get entry :name) name)
|
||||||
|
(set-nth! found 0 entry))))
|
||||||
|
(range 0 (len ks)))
|
||||||
|
(nth found 0))))
|
||||||
|
|
||||||
|
(define er-vm-list-opcodes (fn () (keys (er-vm-opcodes-get))))
|
||||||
|
|
||||||
|
;; ── Phase 9i — host opcode-id resolution ────────────────────────
|
||||||
|
;; When the OCaml `erlang_ext` extension is registered (Phase 9h), the
|
||||||
|
;; runtime exposes `extension-opcode-id` which maps an "erlang.OP_*"
|
||||||
|
;; name to the host-assigned id (222-239). We consult it so the SX
|
||||||
|
;; side and the OCaml side agree on ids; when it returns nil (name not
|
||||||
|
;; registered) we fall back to the stub-local id.
|
||||||
|
;;
|
||||||
|
;; NOTE: this requires a binary with the VM extension mechanism (the
|
||||||
|
;; vm-ext phase-A..E cherry-pick + Sx_vm_extensions force-link). The
|
||||||
|
;; loop builds and runs against exactly that binary
|
||||||
|
;; (hosts/ocaml/_build/default/bin/sx_server.exe). `extension-opcode-id`
|
||||||
|
;; resolves lazily at call time, so merely loading this file is safe;
|
||||||
|
;; only invoking the resolver on a binary that lacks the primitive
|
||||||
|
;; would raise.
|
||||||
|
|
||||||
|
(define er-vm-host-opcode-id
|
||||||
|
(fn (ext-name)
|
||||||
|
(extension-opcode-id ext-name)))
|
||||||
|
|
||||||
|
(define er-vm-effective-opcode-id
|
||||||
|
(fn (ext-name stub-id)
|
||||||
|
(let ((host (extension-opcode-id ext-name)))
|
||||||
|
(cond
|
||||||
|
(= host nil) stub-id
|
||||||
|
:else host))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
er-vm-dispatch
|
||||||
|
(fn
|
||||||
|
(id operands)
|
||||||
|
(let
|
||||||
|
((entry (er-vm-lookup-opcode-by-id id)))
|
||||||
|
(if
|
||||||
|
(= entry nil)
|
||||||
|
(error (str "Erlang VM: unknown opcode id " id))
|
||||||
|
((get entry :handler) operands)))))
|
||||||
|
|
||||||
|
(define
|
||||||
|
er-vm-dispatch-by-name
|
||||||
|
(fn
|
||||||
|
(name operands)
|
||||||
|
(let
|
||||||
|
((entry (er-vm-lookup-opcode-by-name name)))
|
||||||
|
(if
|
||||||
|
(= entry nil)
|
||||||
|
(error (str "Erlang VM: unknown opcode name '" name "'"))
|
||||||
|
((get entry :handler) operands)))))
|
||||||
|
|
||||||
|
;; ── Phase 9c — effect opcodes (perform / handle) ────────────────
|
||||||
|
;; Stub algebraic-effects-style operators. OP_PERFORM raises a tagged
|
||||||
|
;; exception; OP_HANDLE wraps a thunk in `guard` and catches matching
|
||||||
|
;; effects, passing the args to the handler. The real specialization
|
||||||
|
;; (constant-time effect dispatch, single-shot vs multi-shot continuations)
|
||||||
|
;; lands when 9a integrates.
|
||||||
|
|
||||||
|
(define er-vm-effect-marker?
|
||||||
|
(fn (c effect-name)
|
||||||
|
(and (= (type-of c) "dict")
|
||||||
|
(= (get c :tag) "vm-effect")
|
||||||
|
(= (get c :effect) effect-name))))
|
||||||
|
|
||||||
|
(define er-vm-op-perform
|
||||||
|
(fn (operands)
|
||||||
|
(raise {:tag "vm-effect" :effect (nth operands 0) :args (nth operands 1)})))
|
||||||
|
|
||||||
|
(define er-vm-op-handle
|
||||||
|
(fn (operands)
|
||||||
|
(let ((thunk (nth operands 0))
|
||||||
|
(effect-name (nth operands 1))
|
||||||
|
(handler (nth operands 2))
|
||||||
|
(result (list nil))
|
||||||
|
(caught (list false))
|
||||||
|
(rethrow (list nil)))
|
||||||
|
(guard
|
||||||
|
(c
|
||||||
|
(:else
|
||||||
|
(cond
|
||||||
|
(er-vm-effect-marker? c effect-name)
|
||||||
|
(do (set-nth! caught 0 true)
|
||||||
|
(set-nth! result 0 (handler (get c :args))))
|
||||||
|
:else (set-nth! rethrow 0 c))))
|
||||||
|
(set-nth! result 0 (thunk)))
|
||||||
|
(cond
|
||||||
|
(not (= (nth rethrow 0) nil)) (raise (nth rethrow 0))
|
||||||
|
:else (nth result 0)))))
|
||||||
|
|
||||||
|
;; ── Phase 9d — receive scan opcode ────────────────────────────
|
||||||
|
;; Selective receive primitive. Scans a mailbox value-list in arrival
|
||||||
|
;; order; for each value, tries each clause's pattern (binding into
|
||||||
|
;; env on success); on match returns `{:matched true :index N :body B}`
|
||||||
|
;; — the caller decides what to do with the index (queue-delete) and
|
||||||
|
;; the body (eval in the now-mutated env). On miss returns
|
||||||
|
;; `{:matched false}`, the caller arranges suspension (via OP_PERFORM).
|
||||||
|
;;
|
||||||
|
;; Operands: (clauses mbox-list env)
|
||||||
|
;; clauses — list of {:pattern :guards :body} dicts
|
||||||
|
;; mbox-list — SX list of message values
|
||||||
|
;; env — env dict (mutated on match)
|
||||||
|
|
||||||
|
(define er-vm-receive-try-clauses
|
||||||
|
(fn (clauses msg env i)
|
||||||
|
(cond
|
||||||
|
(>= i (len clauses)) {:matched false}
|
||||||
|
:else
|
||||||
|
(let ((c (nth clauses i)) (snap (er-env-copy env)))
|
||||||
|
(cond
|
||||||
|
(and
|
||||||
|
(er-match! (get c :pattern) msg env)
|
||||||
|
(er-eval-guards (get c :guards) env))
|
||||||
|
{:matched true :body (get c :body)}
|
||||||
|
:else
|
||||||
|
(do (er-env-restore! env snap)
|
||||||
|
(er-vm-receive-try-clauses clauses msg env (+ i 1))))))))
|
||||||
|
|
||||||
|
(define er-vm-receive-scan-loop
|
||||||
|
(fn (clauses mbox env i)
|
||||||
|
(cond
|
||||||
|
(>= i (len mbox)) {:matched false}
|
||||||
|
:else
|
||||||
|
(let ((msg (nth mbox i))
|
||||||
|
(cr (er-vm-receive-try-clauses clauses msg env 0)))
|
||||||
|
(cond
|
||||||
|
(get cr :matched) {:matched true :index i :body (get cr :body)}
|
||||||
|
:else (er-vm-receive-scan-loop clauses mbox env (+ i 1)))))))
|
||||||
|
|
||||||
|
(define er-vm-op-receive-scan
|
||||||
|
(fn (operands)
|
||||||
|
(er-vm-receive-scan-loop (nth operands 0) (nth operands 1) (nth operands 2) 0)))
|
||||||
|
|
||||||
|
;; ── Phase 9e — spawn / send + lightweight scheduler ─────────────
|
||||||
|
;; Stub register-machine process layout for the eventual fast scheduler.
|
||||||
|
;; A VM-process is `{:id :registers :mailbox :state :initial-fn :initial-args}`.
|
||||||
|
;; Registers is a vector (SX list, mutated via set-nth!) — fixed slot count
|
||||||
|
;; per process so cells don't grow during execution. Mailbox is an SX list.
|
||||||
|
;; State is one of "runnable" / "waiting" / "dead". This sits PARALLEL to
|
||||||
|
;; the existing `er-scheduler` (which is the language-level scheduler) —
|
||||||
|
;; the VM scheduler will eventually take over once 9a integrates and
|
||||||
|
;; bytecode-compiled Erlang runs against it.
|
||||||
|
|
||||||
|
(define er-vm-procs (list {}))
|
||||||
|
(define er-vm-procs-get (fn () (nth er-vm-procs 0)))
|
||||||
|
(define er-vm-procs-reset!
|
||||||
|
(fn () (do (set-nth! er-vm-procs 0 {}) (set-nth! er-vm-next-pid 0 0))))
|
||||||
|
|
||||||
|
(define er-vm-next-pid (list 0))
|
||||||
|
|
||||||
|
(define er-vm-proc-new!
|
||||||
|
(fn (initial-fn initial-args)
|
||||||
|
(let ((pid (nth er-vm-next-pid 0)))
|
||||||
|
(set-nth! er-vm-next-pid 0 (+ pid 1))
|
||||||
|
(let ((proc
|
||||||
|
{:id pid
|
||||||
|
:registers (list nil nil nil nil nil nil nil nil)
|
||||||
|
:mailbox (list)
|
||||||
|
:state "runnable"
|
||||||
|
:initial-fn initial-fn
|
||||||
|
:initial-args initial-args}))
|
||||||
|
(dict-set! (er-vm-procs-get) (str pid) proc)
|
||||||
|
pid))))
|
||||||
|
|
||||||
|
(define er-vm-proc-get (fn (pid) (get (er-vm-procs-get) (str pid))))
|
||||||
|
|
||||||
|
(define er-vm-proc-send!
|
||||||
|
(fn (pid msg)
|
||||||
|
(let ((proc (er-vm-proc-get pid)))
|
||||||
|
(cond
|
||||||
|
(= proc nil) false
|
||||||
|
:else
|
||||||
|
(do
|
||||||
|
(dict-set! proc :mailbox (append (get proc :mailbox) (list msg)))
|
||||||
|
(when (= (get proc :state) "waiting")
|
||||||
|
(dict-set! proc :state "runnable"))
|
||||||
|
true)))))
|
||||||
|
|
||||||
|
(define er-vm-proc-mailbox (fn (pid) (get (er-vm-proc-get pid) :mailbox)))
|
||||||
|
(define er-vm-proc-state (fn (pid) (get (er-vm-proc-get pid) :state)))
|
||||||
|
(define er-vm-proc-count (fn () (len (keys (er-vm-procs-get)))))
|
||||||
|
|
||||||
|
(define er-vm-op-spawn
|
||||||
|
(fn (operands)
|
||||||
|
(er-vm-proc-new! (nth operands 0) (nth operands 1))))
|
||||||
|
|
||||||
|
(define er-vm-op-send
|
||||||
|
(fn (operands)
|
||||||
|
(er-vm-proc-send! (nth operands 0) (nth operands 1))))
|
||||||
|
|
||||||
|
;; ── Phase 9f — hot-BIF dispatch table ──────────────────────────
|
||||||
|
;; Specialized opcodes for the BIFs that the bytecode compiler emits
|
||||||
|
;; on hot call sites. The handler is the underlying `er-bif-*` impl
|
||||||
|
;; directly — same `(vs)` signature as the dispatcher uses for
|
||||||
|
;; operands, so the cost is the opcode-id → handler hop with no
|
||||||
|
;; registry-key string lookup. Cold BIFs continue going through the
|
||||||
|
;; general path (`er-apply-bif` / `er-lookup-bif`).
|
||||||
|
;;
|
||||||
|
;; Opcodes 136-159 reserved for hot BIFs.
|
||||||
|
|
||||||
|
;; ── Phase 9b — pattern-match opcodes ────────────────────────────
|
||||||
|
;; Each handler takes a list (pattern-ast value env) and returns
|
||||||
|
;; true/false, mutating env on success (same contract as the
|
||||||
|
;; existing er-match-tuple / er-match-cons / er-match-binary).
|
||||||
|
;; Wire these as wrappers for now; the real opcodes will eventually
|
||||||
|
;; have register-machine semantics and skip the AST-walk overhead.
|
||||||
|
|
||||||
|
(define
|
||||||
|
er-vm-register-erlang-opcodes!
|
||||||
|
(fn
|
||||||
|
()
|
||||||
|
(er-vm-register-opcode!
|
||||||
|
128
|
||||||
|
"OP_PATTERN_TUPLE"
|
||||||
|
(fn
|
||||||
|
(operands)
|
||||||
|
(er-match-tuple
|
||||||
|
(nth operands 0)
|
||||||
|
(nth operands 1)
|
||||||
|
(nth operands 2))))
|
||||||
|
(er-vm-register-opcode!
|
||||||
|
129
|
||||||
|
"OP_PATTERN_LIST"
|
||||||
|
(fn
|
||||||
|
(operands)
|
||||||
|
(er-match-cons
|
||||||
|
(nth operands 0)
|
||||||
|
(nth operands 1)
|
||||||
|
(nth operands 2))))
|
||||||
|
(er-vm-register-opcode!
|
||||||
|
130
|
||||||
|
"OP_PATTERN_BINARY"
|
||||||
|
(fn
|
||||||
|
(operands)
|
||||||
|
(er-match-binary
|
||||||
|
(nth operands 0)
|
||||||
|
(nth operands 1)
|
||||||
|
(nth operands 2))))
|
||||||
|
(er-vm-register-opcode! 131 "OP_PERFORM" er-vm-op-perform)
|
||||||
|
(er-vm-register-opcode! 132 "OP_HANDLE" er-vm-op-handle)
|
||||||
|
(er-vm-register-opcode! 133 "OP_RECEIVE_SCAN" er-vm-op-receive-scan)
|
||||||
|
(er-vm-register-opcode! 134 "OP_SPAWN" er-vm-op-spawn)
|
||||||
|
(er-vm-register-opcode! 135 "OP_SEND" er-vm-op-send)
|
||||||
|
;; Phase 9f — hot BIFs
|
||||||
|
(er-vm-register-opcode! 136 "OP_BIF_LENGTH" er-bif-length)
|
||||||
|
(er-vm-register-opcode! 137 "OP_BIF_HD" er-bif-hd)
|
||||||
|
(er-vm-register-opcode! 138 "OP_BIF_TL" er-bif-tl)
|
||||||
|
(er-vm-register-opcode! 139 "OP_BIF_ELEMENT" er-bif-element)
|
||||||
|
(er-vm-register-opcode! 140 "OP_BIF_TUPLE_SIZE" er-bif-tuple-size)
|
||||||
|
(er-vm-register-opcode! 141 "OP_BIF_LISTS_REVERSE" er-bif-lists-reverse)
|
||||||
|
(er-vm-register-opcode! 142 "OP_BIF_IS_INTEGER" er-bif-is-integer)
|
||||||
|
(er-vm-register-opcode! 143 "OP_BIF_IS_ATOM" er-bif-is-atom)
|
||||||
|
(er-vm-register-opcode! 144 "OP_BIF_IS_LIST" er-bif-is-list)
|
||||||
|
(er-vm-register-opcode! 145 "OP_BIF_IS_TUPLE" er-bif-is-tuple)
|
||||||
|
(er-mk-atom "ok")))
|
||||||
|
|
||||||
|
(er-vm-register-erlang-opcodes!)
|
||||||
1
next/.gitignore
vendored
Normal file
1
next/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
data/
|
||||||
170
next/README.md
Normal file
170
next/README.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# next — fed-sx Milestone 1 kernel
|
||||||
|
|
||||||
|
Single-instance, single-actor fed-sx server built as Erlang-on-SX modules.
|
||||||
|
See `plans/fed-sx-design.md` for the architecture and
|
||||||
|
`plans/fed-sx-milestone-1.md` for the build plan + per-step progress log.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Both Step 9 smoke proof points are functional **in-process**:
|
||||||
|
|
||||||
|
- **9a-pure (verb extensibility)** — `Create{DefineActivity{Pin}}` registers Pin
|
||||||
|
at runtime; subsequent `Pin{path, cid}` activities fold into a pin-state
|
||||||
|
projection. Zero kernel code between definition and use.
|
||||||
|
See `next/tests/smoke_pin_pure.sh`.
|
||||||
|
- **9b-pure (reactive application)** — A trigger projection matches Notes
|
||||||
|
tagged `smoketest` and derives a `TestEcho` carrying the source CID.
|
||||||
|
See `next/tests/smoke_app_pure.sh`.
|
||||||
|
|
||||||
|
The remaining `9a-tcp` / `9b-tcp` deliverables layer TCP transport on top — see
|
||||||
|
*Substrate gaps* below.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
next/
|
||||||
|
├── kernel/ Erlang-on-SX kernel modules (.erl)
|
||||||
|
├── genesis/ SX source files for the bootstrap bundle
|
||||||
|
├── tests/ Bash test scripts driving sx_server.exe via the epoch protocol
|
||||||
|
└── data/ Runtime state — gitignored
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
| Module | Role |
|
||||||
|
|-----------------------|------------------------------------------------------------------------|
|
||||||
|
| `nx_cid.erl` | Canonical CID wrapper around the host `cid:to_string` BIF |
|
||||||
|
| `envelope.erl` | Activity envelope shape, canonical bytes, time-aware sig verify |
|
||||||
|
| `log.erl` | Per-actor in-memory append log (open / append / tip / replay / entries) |
|
||||||
|
| `registry.erl` | Pure-functional + gen_server-wrapped registry keyed by Kind |
|
||||||
|
| `pipeline.erl` | Validation driver + stage_envelope/signature/replay/schema |
|
||||||
|
| `projection.erl` | Pure projection driver + gen_server-per-projection wrapper |
|
||||||
|
| `outbox.erl` | Envelope construct + sign + publish orchestrator + broadcast |
|
||||||
|
| `bootstrap.erl` | Genesis read/build/verify/load + one-call `start/3` kernel bring-up |
|
||||||
|
| `define_registry.erl` | Meta-projection fold for `Create{Define*}` → registry |
|
||||||
|
| `sandbox.erl` | `eval_pure/2,3` try/catch envelope for projection folds |
|
||||||
|
| `nx_kernel.erl` | Long-lived runtime orchestrator; per-actor bucketed state (m2 Step 1a) |
|
||||||
|
| `http_server.erl` | route/1,2 + format-aware GET + POST + Accept header content negotiation |
|
||||||
|
|
||||||
|
## Genesis bundle
|
||||||
|
|
||||||
|
`next/genesis/` contains 31 SX files across 7 sections, all consumed as data
|
||||||
|
(read + serialised by `bootstrap:populate_registry`, not eval'd):
|
||||||
|
|
||||||
|
- 3 activity-types — Create, Update, Delete
|
||||||
|
- 10 object-types — SXArtifact, Note, Tombstone, 6 Define* meta-types, Snapshot
|
||||||
|
- 7 projections — activity-log, by-type, by-actor, by-object, actor-state,
|
||||||
|
define-registry, audience-graph
|
||||||
|
- 3 validators — envelope-shape, signature, type-schema
|
||||||
|
- 3 codecs — dag-cbor, raw, dag-json
|
||||||
|
- 2 sig-suites — rsa-sha256-2018, ed25519-2020
|
||||||
|
- 3 audience predicates — Public, Followers, Direct
|
||||||
|
|
||||||
|
`manifest.sx` is the bundle root, listed in dependency-friendly order.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
43 test suites, ~560+ assertions. Each script drives `sx_server.exe` via the
|
||||||
|
epoch protocol — loads the Erlang substrate, loads relevant kernel modules
|
||||||
|
via `code:load_binary` / `erlang-load-module`, then exercises behaviour
|
||||||
|
through `erlang-eval-ast`.
|
||||||
|
|
||||||
|
Conventions:
|
||||||
|
|
||||||
|
- Scripts marked `_pure.sh` exercise pure-functional state.
|
||||||
|
- Scripts marked `_server.sh` (or no suffix) exercise gen_server APIs and
|
||||||
|
must inline `start_link` with operations — the Erlang-on-SX scheduler
|
||||||
|
doesn't preserve spawned processes across separate `erlang-eval-ast`
|
||||||
|
invocations.
|
||||||
|
- `smoke_*_pure.sh` are end-to-end smoke tests demonstrating the §Step 9
|
||||||
|
proof points without TCP / curl / JSON.
|
||||||
|
|
||||||
|
The Erlang-on-SX conformance gate (`bash lib/erlang/conformance.sh`, **729 /
|
||||||
|
729**) is the no-regression contract — every commit on `loops/fed-sx-m1`
|
||||||
|
preserves it.
|
||||||
|
|
||||||
|
## Substrate
|
||||||
|
|
||||||
|
Each `.erl` source file is hot-loaded at boot via
|
||||||
|
`code:load_binary(Mod, Filename, SourceString)` (Phase 7 BIF). Tests drive
|
||||||
|
the runtime via the epoch protocol:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
printf '(epoch 1)\n(load "lib/erlang/runtime.sx")\n(epoch 2)\n<test-expr>\n' \
|
||||||
|
| hosts/ocaml/_build/default/bin/sx_server.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
The kernel calls into these host primitives: `crypto:hash/2`,
|
||||||
|
`cid:from_bytes/1`, `cid:to_string/1`, `file:read_file/1`, `file:write_file/2`,
|
||||||
|
`file:delete/1`, `file:list_dir/1`, `code:load_binary/3`, plus `http:listen/2`
|
||||||
|
(the briefing's allowed scope exception, added to `lib/erlang/runtime.sx`).
|
||||||
|
|
||||||
|
### Substrate gaps (parked work)
|
||||||
|
|
||||||
|
These three gaps block the remaining unchecked deliverables:
|
||||||
|
|
||||||
|
1. **Term codec** (`3b`/`3c`) — **all three substrate fixes done 2026-06-05:**
|
||||||
|
`erlang:binary_to_list/1` and `erlang:list_to_binary/1` registered in
|
||||||
|
`lib/erlang/runtime.sx` (iolist-aware); the tokenizer's `$X` branch
|
||||||
|
emits the decimal char code; `atom_to_list/1` and `integer_to_list/1`
|
||||||
|
now return Erlang charlists (standard Erlang semantics) with `list_to_atom`/
|
||||||
|
`list_to_integer` accepting both charlists and SX strings for back-compat.
|
||||||
|
759/759 conformance. The full term-codec primitive set is in place —
|
||||||
|
Step 3b on-disk segment writer can encode arbitrary Erlang activity
|
||||||
|
terms (atoms, ints, binaries, tuples, lists) into byte sequences using
|
||||||
|
only Erlang-native primitives.
|
||||||
|
|
||||||
|
2. **SX-source eval bridge** — There's no BIF that lets Erlang call into the
|
||||||
|
SX evaluator on a parsed source string. Blocks evaluating the `:schema` /
|
||||||
|
`:fold` / `:predicate` / `:verify` bodies from the genesis bundle. Erlang-fun
|
||||||
|
stand-ins (`pipeline:stage_schema`, `define_registry:fold`, etc.) prove the
|
||||||
|
API shapes; the bridge would let bundle bodies dispatch through them
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
3. **Dict ↔ proplist marshalling for `http:listen/2`** — **done 2026-06-05.**
|
||||||
|
`er-bif-http-listen` marshals the native server's request dict
|
||||||
|
(`{:method :path :query :headers :body}`) into the proplist shape
|
||||||
|
`[{method, Bin}, {path, Bin}, {query, Bin}, {headers, [{Name, Value}]},
|
||||||
|
{body, Bin}]` that `http_server:route/2` consumes, and converts the
|
||||||
|
handler's response proplist back to `{:status :headers :body}` for the
|
||||||
|
native server to serialise. Helpers (`er-request-dict-to-proplist`,
|
||||||
|
`er-proplist-to-dict`, `er-of-sx-deep`, `er-to-sx-deep`,
|
||||||
|
`er-dict-to-header-proplist`, `er-proplist-fill!`) live alongside the
|
||||||
|
BIF wrapper in `lib/erlang/runtime.sx`. The BIF also spawns the handler
|
||||||
|
into a real Erlang process via `er-spawn-fun` + `er-sched-run-all!`
|
||||||
|
so `self()` / `gen_server:call` work inside route handlers (the kernel
|
||||||
|
and projection gen_servers reach the handler this way). Verified by
|
||||||
|
`next/tests/http_marshal.sh` and the live TCP smoke
|
||||||
|
`next/tests/http_server_tcp.sh` / `http_server_start.sh`. Unblocks
|
||||||
|
`Step 8b-start` (TCP listener spawn) and the curl-driven 9a-tcp / 9b-tcp
|
||||||
|
smoke tests.
|
||||||
|
|
||||||
|
### Bringing up the kernel
|
||||||
|
|
||||||
|
For tests, `bootstrap:start/3(ActorId, KeySpec, ActorState)` is the
|
||||||
|
one-call boot:
|
||||||
|
|
||||||
|
```erlang
|
||||||
|
KM = <<1,2,3,4>>,
|
||||||
|
KS = [{key_id, k1}, {algorithm, ed25519}, {value, KM}],
|
||||||
|
AS = [{public_keys, [[{id, k1}, {created, 0}, {value, KM}]]}],
|
||||||
|
Pid = bootstrap:start(alice, KS, AS),
|
||||||
|
%% nx_kernel + registry populated; you now have a kernel.
|
||||||
|
```
|
||||||
|
|
||||||
|
The HTTP layer (`http_server`) and `nx_kernel:publish/1` flow through the
|
||||||
|
same in-process gen_servers; `http_publish_fold.sh` is the end-to-end proof
|
||||||
|
the chain works.
|
||||||
|
|
||||||
|
## What's next (when work resumes)
|
||||||
|
|
||||||
|
In priority order:
|
||||||
|
|
||||||
|
1. **8b-start** — `http_server:start/1` spawns a process hosting `http:listen/2`.
|
||||||
|
(8b-bridge done — see Substrate gap #3.)
|
||||||
|
2. **9a-tcp / 9b-tcp** — replace the in-process smoke scripts with curl-driven
|
||||||
|
versions hitting the running server.
|
||||||
|
3. **Term codec / on-disk log** — needs either a new BIF or a temp-file
|
||||||
|
workaround; current in-memory log keeps everything functional otherwise.
|
||||||
|
4. **SX-source eval bridge** — unlocks real `:schema` / `:fold` body
|
||||||
|
evaluation from the genesis bundle.
|
||||||
0
next/genesis/.gitkeep
Normal file
0
next/genesis/.gitkeep
Normal file
14
next/genesis/activity-types/announce.sx
Normal file
14
next/genesis/activity-types/announce.sx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
;; next/genesis/activity-types/announce.sx
|
||||||
|
;;
|
||||||
|
;; Bootstrap definition of the Announce verb per design §13.5 / m2
|
||||||
|
;; Step 11. An Announce re-broadcasts a peer's activity to the
|
||||||
|
;; announcer's followers: the announcer's outbox carries an Announce
|
||||||
|
;; envelope whose :object is the original activity's CID. Followers
|
||||||
|
;; can re-fetch the wrapped activity from the original instance if
|
||||||
|
;; their projection wants to fold the body.
|
||||||
|
|
||||||
|
(DefineActivity
|
||||||
|
:name "Announce"
|
||||||
|
:doc "Re-broadcast a peer's activity to followers. :object is the CID of the activity being announced. Recipients see the Announce in their inbox / feed; their projection decides whether to fetch the wrapped activity body."
|
||||||
|
:schema (fn (act) (string? (-> act :object)))
|
||||||
|
:semantics (fn (state act) state))
|
||||||
15
next/genesis/activity-types/create.sx
Normal file
15
next/genesis/activity-types/create.sx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
;; next/genesis/activity-types/create.sx
|
||||||
|
;;
|
||||||
|
;; Bootstrap definition of the Create verb per design §3 and §12.2.
|
||||||
|
;; Read as data by the bundler (bootstrap.erl) — never evaluated as
|
||||||
|
;; code. The :schema and :semantics bodies are SX source; the
|
||||||
|
;; validation pipeline (Step 6) and projection scheduler (Step 7)
|
||||||
|
;; evaluate them at the appropriate times.
|
||||||
|
|
||||||
|
(DefineActivity
|
||||||
|
:name "Create"
|
||||||
|
:doc "Publish a new object. Required for actor onboarding and for\n every Define* meta-activity. The activity's :object holds\n the canonical content of the published object."
|
||||||
|
:schema (fn
|
||||||
|
(act)
|
||||||
|
(and (not (nil? (-> act :object))) (string? (-> act :object :type))))
|
||||||
|
:semantics (fn (state act) state))
|
||||||
13
next/genesis/activity-types/delete.sx
Normal file
13
next/genesis/activity-types/delete.sx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
;; next/genesis/activity-types/delete.sx
|
||||||
|
;;
|
||||||
|
;; Bootstrap definition of the Delete verb per design §3 and §12.2.
|
||||||
|
;; Read as data by the bundler — never evaluated as code here. The
|
||||||
|
;; :schema and :semantics bodies are SX source; the validator
|
||||||
|
;; pipeline (Step 6) and projection scheduler (Step 7) evaluate them
|
||||||
|
;; at the appropriate times.
|
||||||
|
|
||||||
|
(DefineActivity
|
||||||
|
:name "Delete"
|
||||||
|
:doc "Tombstone an existing object. :object is the CID of the\n target. Projections fold Delete by removing the object from\n their working indexes; the underlying log line is never\n erased — durability of the historical record is independent\n of projection state."
|
||||||
|
:schema (fn (act) (string? (-> act :object)))
|
||||||
|
:semantics (fn (state act) state))
|
||||||
13
next/genesis/activity-types/endorse.sx
Normal file
13
next/genesis/activity-types/endorse.sx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
;; next/genesis/activity-types/endorse.sx
|
||||||
|
;;
|
||||||
|
;; Bootstrap definition of the Endorse verb per design §13.5 / m2
|
||||||
|
;; Step 11. An Endorse expresses cross-actor signal on a target
|
||||||
|
;; activity (like / share / etc.). :object is the target activity's
|
||||||
|
;; CID; :kind is the endorsement variant (string). Projections
|
||||||
|
;; aggregate endorsements into counters / heat / ranking signals.
|
||||||
|
|
||||||
|
(DefineActivity
|
||||||
|
:name "Endorse"
|
||||||
|
:doc "Cross-actor signal on a target activity. :object is the target activity's CID; :kind is the endorsement variant (e.g. 'like', 'share'). Projections aggregate endorsements into counters / heat / ranking signals."
|
||||||
|
:schema (fn (act) (and (string? (-> act :object)) (string? (-> act :kind))))
|
||||||
|
:semantics (fn (state act) state))
|
||||||
15
next/genesis/activity-types/update.sx
Normal file
15
next/genesis/activity-types/update.sx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
;; next/genesis/activity-types/update.sx
|
||||||
|
;;
|
||||||
|
;; Bootstrap definition of the Update verb per design §3 and §12.2.
|
||||||
|
;; Read as data by the bundler — never evaluated as code here. The
|
||||||
|
;; :schema and :semantics bodies are SX source; the validator
|
||||||
|
;; pipeline (Step 6) and projection scheduler (Step 7) evaluate them
|
||||||
|
;; at the appropriate times.
|
||||||
|
|
||||||
|
(DefineActivity
|
||||||
|
:name "Update"
|
||||||
|
:doc "Patch or replace an existing object. :object is the CID of\n the target; :patch is the field-level edit. Behaviour is\n delegated to per-object-type semantics — e.g. an Update of a\n DefineActivity supersedes the prior registry entry; an\n Update of a Person actor rotates keys via :patch :add-publicKey\n + :patch :supersede."
|
||||||
|
:schema (fn
|
||||||
|
(act)
|
||||||
|
(and (string? (-> act :object)) (not (nil? (-> act :patch)))))
|
||||||
|
:semantics (fn (state act) state))
|
||||||
14
next/genesis/audience/direct.sx
Normal file
14
next/genesis/audience/direct.sx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
;; next/genesis/audience/direct.sx
|
||||||
|
;;
|
||||||
|
;; Direct audience: an actor is a member iff they are
|
||||||
|
;; explicitly named in the activity's :to or :cc lists. No
|
||||||
|
;; group expansion — true direct addressing only.
|
||||||
|
|
||||||
|
(DefineAudience
|
||||||
|
:name "Direct"
|
||||||
|
:doc "Direct-addressing predicate. Tests literal membership\n in the activity's :to or :cc."
|
||||||
|
:member-of (fn
|
||||||
|
(actor audience)
|
||||||
|
(or
|
||||||
|
(member? actor (-> audience :to))
|
||||||
|
(member? actor (-> audience :cc)))))
|
||||||
14
next/genesis/audience/followers.sx
Normal file
14
next/genesis/audience/followers.sx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
;; next/genesis/audience/followers.sx
|
||||||
|
;;
|
||||||
|
;; Followers audience: an actor is a member iff they appear in
|
||||||
|
;; the audience-owner's :followers set in the audience-graph
|
||||||
|
;; projection. Federation (m2) wires this to peer delivery.
|
||||||
|
|
||||||
|
(DefineAudience
|
||||||
|
:name "Followers"
|
||||||
|
:doc "Followers-of-owner predicate. Looks up the\n audience-graph projection's :followers list for the\n audience owner and tests membership."
|
||||||
|
:member-of (fn
|
||||||
|
(actor audience)
|
||||||
|
(member?
|
||||||
|
actor
|
||||||
|
(-> (get-projection :audience-graph) (-> audience :owner) :followers))))
|
||||||
9
next/genesis/audience/public.sx
Normal file
9
next/genesis/audience/public.sx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
;; next/genesis/audience/public.sx
|
||||||
|
;;
|
||||||
|
;; Public audience: every actor is a member. Maps to the AP
|
||||||
|
;; magic id `https://www.w3.org/ns/activitystreams#Public`.
|
||||||
|
|
||||||
|
(DefineAudience
|
||||||
|
:name "Public"
|
||||||
|
:doc "Public audience predicate. Always returns true — every\n actor on the network is considered a member."
|
||||||
|
:member-of (fn (actor audience) true))
|
||||||
13
next/genesis/codecs/dag-cbor.sx
Normal file
13
next/genesis/codecs/dag-cbor.sx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
;; next/genesis/codecs/dag-cbor.sx
|
||||||
|
;;
|
||||||
|
;; Canonical CBOR encoding per IPLD dag-cbor. Used to compute
|
||||||
|
;; envelope canonical bytes for signature coverage and to serialise
|
||||||
|
;; the genesis bundle itself. In Erlang-on-SX mode the kernel
|
||||||
|
;; dispatches to the host cid:to_string substrate (Step 1b) when
|
||||||
|
;; this codec is requested.
|
||||||
|
|
||||||
|
(DefineCodec
|
||||||
|
:name "dag-cbor"
|
||||||
|
:doc "Deterministic CBOR with dag-cbor restrictions: sorted\n map keys, no floats unless required, no indefinite-length\n items. The canonical wire format for fed-sx artifacts."
|
||||||
|
:encode (fn (term) (host-codec :dag-cbor :encode term))
|
||||||
|
:decode (fn (bytes) (host-codec :dag-cbor :decode bytes)))
|
||||||
12
next/genesis/codecs/dag-json.sx
Normal file
12
next/genesis/codecs/dag-json.sx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
;; next/genesis/codecs/dag-json.sx
|
||||||
|
;;
|
||||||
|
;; JSON encoding with dag-json restrictions per IPLD: sorted map
|
||||||
|
;; keys, no NaN / Infinity, no comments, CIDs as `{"/": "..."}`.
|
||||||
|
;; Used as the human-readable wire format for ActivityPub interop
|
||||||
|
;; (JSON-LD over dag-json).
|
||||||
|
|
||||||
|
(DefineCodec
|
||||||
|
:name "dag-json"
|
||||||
|
:doc "Deterministic JSON with dag-json restrictions. Sorted\n keys, CIDs as the {\"/\": \"...\"} object. Used by the\n HTTP server (Step 8) for application/json responses."
|
||||||
|
:encode (fn (term) (host-codec :dag-json :encode term))
|
||||||
|
:decode (fn (bytes) (host-codec :dag-json :decode bytes)))
|
||||||
12
next/genesis/codecs/raw.sx
Normal file
12
next/genesis/codecs/raw.sx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
;; next/genesis/codecs/raw.sx
|
||||||
|
;;
|
||||||
|
;; Identity codec — input bytes pass through unchanged in both
|
||||||
|
;; directions. Used for already-encoded payloads and for binary
|
||||||
|
;; artifacts (images, archives) whose CID is computed over the
|
||||||
|
;; raw bytes directly.
|
||||||
|
|
||||||
|
(DefineCodec
|
||||||
|
:name "raw"
|
||||||
|
:doc "Identity codec. The CID's multicodec byte is 0x55.\n :encode and :decode return their input unchanged."
|
||||||
|
:encode (fn (bytes) bytes)
|
||||||
|
:decode (fn (bytes) bytes))
|
||||||
51
next/genesis/manifest.sx
Normal file
51
next/genesis/manifest.sx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
;; next/genesis/manifest.sx
|
||||||
|
;;
|
||||||
|
;; Genesis bundle root per design §12.2. Lists every definition file
|
||||||
|
;; that gets packed into the bundle. The bundler (bootstrap.erl)
|
||||||
|
;; walks this manifest, reads each referenced file, parses its
|
||||||
|
;; top-level form, and inserts it into the bundle dict at the
|
||||||
|
;; appropriate section path.
|
||||||
|
;;
|
||||||
|
;; The bundle CID is the content-address of the resulting dag-cbor
|
||||||
|
;; (or v1 stand-in) blob over the assembled dict. That CID is
|
||||||
|
;; baked into the kernel at build time and re-verified on startup
|
||||||
|
;; per design §12.3.
|
||||||
|
;;
|
||||||
|
;; Section values are bare parenthesised paths (data lists, not
|
||||||
|
;; function calls) — the manifest is consumed by `parse`, not
|
||||||
|
;; `eval`. Empty sections are written as `()`.
|
||||||
|
|
||||||
|
(GenesisManifest
|
||||||
|
:version "0.0.1"
|
||||||
|
:kernel-version "1.0.0-m1"
|
||||||
|
:activity-types ("activity-types/create.sx"
|
||||||
|
"activity-types/update.sx"
|
||||||
|
"activity-types/delete.sx"
|
||||||
|
"activity-types/announce.sx"
|
||||||
|
"activity-types/endorse.sx")
|
||||||
|
:object-types ("object-types/sx-artifact.sx"
|
||||||
|
"object-types/note.sx"
|
||||||
|
"object-types/tombstone.sx"
|
||||||
|
"object-types/person.sx"
|
||||||
|
"object-types/service.sx"
|
||||||
|
"object-types/group.sx"
|
||||||
|
"object-types/define-activity.sx"
|
||||||
|
"object-types/define-object.sx"
|
||||||
|
"object-types/define-projection.sx"
|
||||||
|
"object-types/define-validator.sx"
|
||||||
|
"object-types/define-codec.sx"
|
||||||
|
"object-types/define-sig-suite.sx"
|
||||||
|
"object-types/snapshot.sx")
|
||||||
|
:projections ("projections/activity-log.sx"
|
||||||
|
"projections/by-type.sx"
|
||||||
|
"projections/by-actor.sx"
|
||||||
|
"projections/by-object.sx"
|
||||||
|
"projections/actor-state.sx"
|
||||||
|
"projections/define-registry.sx"
|
||||||
|
"projections/audience-graph.sx")
|
||||||
|
:validators ("validators/envelope-shape.sx"
|
||||||
|
"validators/signature.sx"
|
||||||
|
"validators/type-schema.sx")
|
||||||
|
:codecs ("codecs/dag-cbor.sx" "codecs/raw.sx" "codecs/dag-json.sx")
|
||||||
|
:sig-suites ("sig-suites/rsa-sha256-2018.sx" "sig-suites/ed25519-2020.sx")
|
||||||
|
:audience ("audience/public.sx" "audience/followers.sx" "audience/direct.sx"))
|
||||||
12
next/genesis/object-types/define-activity.sx
Normal file
12
next/genesis/object-types/define-activity.sx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
;; next/genesis/object-types/define-activity.sx
|
||||||
|
;;
|
||||||
|
;; Meta-object that registers a new activity verb. Published as
|
||||||
|
;; Create{DefineActivity{...}}; the define-registry projection
|
||||||
|
;; folds it into the activity-types registry. Per design §5.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "DefineActivity"
|
||||||
|
:doc "Activity-type registration. :name is the verb (e.g.\n \"Pin\"); :schema is an SX predicate over activity\n envelopes; :semantics is an optional state-fold body."
|
||||||
|
:schema (fn
|
||||||
|
(obj)
|
||||||
|
(and (string? (-> obj :name)) (not (nil? (-> obj :schema))))))
|
||||||
15
next/genesis/object-types/define-codec.sx
Normal file
15
next/genesis/object-types/define-codec.sx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
;; next/genesis/object-types/define-codec.sx
|
||||||
|
;;
|
||||||
|
;; Meta-object that registers a content codec — an encode/decode
|
||||||
|
;; pair. The bootstrap bundle ships dag-cbor, raw, and dag-json
|
||||||
|
;; codecs; new codecs can be added via Create{DefineCodec{...}}.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "DefineCodec"
|
||||||
|
:doc "Codec registration. :name identifies the codec ('dag-cbor',\n 'raw', 'dag-json', ...); :encode and :decode are the\n SX bodies the kernel calls when serialising / parsing\n artifacts under this codec."
|
||||||
|
:schema (fn
|
||||||
|
(obj)
|
||||||
|
(and
|
||||||
|
(string? (-> obj :name))
|
||||||
|
(not (nil? (-> obj :encode)))
|
||||||
|
(not (nil? (-> obj :decode))))))
|
||||||
12
next/genesis/object-types/define-object.sx
Normal file
12
next/genesis/object-types/define-object.sx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
;; next/genesis/object-types/define-object.sx
|
||||||
|
;;
|
||||||
|
;; Meta-object that registers a new object-type. Bootstrap-level —
|
||||||
|
;; runtime registration of new object types (e.g. DefineSubscription
|
||||||
|
;; in the Step 9b smoke test) flows through this.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "DefineObject"
|
||||||
|
:doc "Object-type registration. :name is the type tag (e.g.\n \"PinSpec\"); :schema is an SX predicate over object\n forms of that type."
|
||||||
|
:schema (fn
|
||||||
|
(obj)
|
||||||
|
(and (string? (-> obj :name)) (not (nil? (-> obj :schema))))))
|
||||||
16
next/genesis/object-types/define-projection.sx
Normal file
16
next/genesis/object-types/define-projection.sx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
;; next/genesis/object-types/define-projection.sx
|
||||||
|
;;
|
||||||
|
;; Meta-object that registers a new projection. The projection
|
||||||
|
;; scheduler (Step 7) spawns one gen_server per registered
|
||||||
|
;; projection and feeds activities through its :fold body in
|
||||||
|
;; sandbox mode.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "DefineProjection"
|
||||||
|
:doc "Projection registration. :name is the projection key;\n :initial-state is the empty state value; :fold is the\n pure (state activity) -> state function evaluated in\n sandbox mode per activity."
|
||||||
|
:schema (fn
|
||||||
|
(obj)
|
||||||
|
(and
|
||||||
|
(string? (-> obj :name))
|
||||||
|
(not (nil? (-> obj :initial-state)))
|
||||||
|
(not (nil? (-> obj :fold))))))
|
||||||
12
next/genesis/object-types/define-sig-suite.sx
Normal file
12
next/genesis/object-types/define-sig-suite.sx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
;; next/genesis/object-types/define-sig-suite.sx
|
||||||
|
;;
|
||||||
|
;; Meta-object that registers a signature suite. Bootstrap ships
|
||||||
|
;; rsa-sha256-2018 and ed25519-2020; the suite name maps an
|
||||||
|
;; algorithm to a :verify body and a :key-format predicate.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "DefineSigSuite"
|
||||||
|
:doc "Signature suite registration. :name identifies the suite\n ('rsa-sha256-2018', 'ed25519-2020', ...); :verify is the\n SX (canonical-bytes signature key) -> bool body; the\n envelope-signature validator dispatches by suite name."
|
||||||
|
:schema (fn
|
||||||
|
(obj)
|
||||||
|
(and (string? (-> obj :name)) (not (nil? (-> obj :verify))))))
|
||||||
12
next/genesis/object-types/define-validator.sx
Normal file
12
next/genesis/object-types/define-validator.sx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
;; next/genesis/object-types/define-validator.sx
|
||||||
|
;;
|
||||||
|
;; Meta-object that registers a validator predicate. The validation
|
||||||
|
;; pipeline (Step 6) consults registered validators by name when
|
||||||
|
;; running its stages.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "DefineValidator"
|
||||||
|
:doc "Validator registration. :name is the validator key (e.g.\n \"envelope-shape\"); :predicate is the SX (activity) ->\n ok|{error, R} body."
|
||||||
|
:schema (fn
|
||||||
|
(obj)
|
||||||
|
(and (string? (-> obj :name)) (not (nil? (-> obj :predicate))))))
|
||||||
11
next/genesis/object-types/group.sx
Normal file
11
next/genesis/object-types/group.sx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
;; next/genesis/object-types/group.sx
|
||||||
|
;;
|
||||||
|
;; Per design §9.1: a Group is a multi-controller actor — typically
|
||||||
|
;; a working group, channel, or collective whose membership is
|
||||||
|
;; managed via Add/Remove activities. Sig-suite validation honours
|
||||||
|
;; the current key-set rather than a single keypair.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "Group"
|
||||||
|
:doc "Multi-controller actor. :name is the group's display name; :preferredUsername is the local handle; :summary is the description; :icon is a CID or URL; :members is the current member list (managed via Add/Remove)."
|
||||||
|
:schema (fn (obj) (string? (-> obj :name))))
|
||||||
10
next/genesis/object-types/note.sx
Normal file
10
next/genesis/object-types/note.sx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
;; next/genesis/object-types/note.sx
|
||||||
|
;;
|
||||||
|
;; Short message intended for an audience, ActivityPub-Note-compatible.
|
||||||
|
;; Used by the Step 9b reactive smoke test (Note tagged "smoketest"
|
||||||
|
;; matches the Topic subscription).
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "Note"
|
||||||
|
:doc "Short authored message. :content is the body text;\n :tags is a list of subscription-routable tags."
|
||||||
|
:schema (fn (obj) (string? (-> obj :content))))
|
||||||
11
next/genesis/object-types/person.sx
Normal file
11
next/genesis/object-types/person.sx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
;; next/genesis/object-types/person.sx
|
||||||
|
;;
|
||||||
|
;; Per design §9.1: a Person is the canonical actor type for a
|
||||||
|
;; human-controlled identity. Bootstrapped via Create{Person{...}}
|
||||||
|
;; as the actor's first activity (see nx_kernel:bootstrap_actor/4).
|
||||||
|
;; ActivityPub-Person-compatible.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "Person"
|
||||||
|
:doc "Human-controlled actor. :name is the display name; :preferredUsername is the local handle; :summary is the profile bio; :icon is a CID or URL."
|
||||||
|
:schema (fn (obj) (string? (-> obj :name))))
|
||||||
11
next/genesis/object-types/service.sx
Normal file
11
next/genesis/object-types/service.sx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
;; next/genesis/object-types/service.sx
|
||||||
|
;;
|
||||||
|
;; Per design §9.1: a Service is a non-human actor — a bot, an
|
||||||
|
;; automated feed, an organisational publisher. Same activity
|
||||||
|
;; surface as Person, different ActivityPub Actor type. Tooling
|
||||||
|
;; treats a Service identically to a Person except for UX hints.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "Service"
|
||||||
|
:doc "Automated / programmatic actor. :name is the display name; :preferredUsername is the local handle; :summary is the profile bio; :icon is a CID or URL."
|
||||||
|
:schema (fn (obj) (string? (-> obj :name))))
|
||||||
13
next/genesis/object-types/snapshot.sx
Normal file
13
next/genesis/object-types/snapshot.sx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
;; next/genesis/object-types/snapshot.sx
|
||||||
|
;;
|
||||||
|
;; Projection state checkpoint. The projection scheduler emits
|
||||||
|
;; Snapshot{projection-name, state-cid, log-seq} periodically;
|
||||||
|
;; cold starts read the most recent Snapshot and replay only
|
||||||
|
;; activities after :log-seq. Per design §10.5.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "Snapshot"
|
||||||
|
:doc "Projection-state checkpoint. :projection-name identifies\n the projection; :state-cid is the content-address of\n the snapshotted state value; :log-seq is the activity\n sequence number the snapshot was taken at."
|
||||||
|
:schema (fn
|
||||||
|
(obj)
|
||||||
|
(and (string? (-> obj :projection-name)) (string? (-> obj :state-cid)))))
|
||||||
10
next/genesis/object-types/sx-artifact.sx
Normal file
10
next/genesis/object-types/sx-artifact.sx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
;; next/genesis/object-types/sx-artifact.sx
|
||||||
|
;;
|
||||||
|
;; Content-addressed SX source — a library, component, or
|
||||||
|
;; executable form published via Create{SXArtifact{...}}.
|
||||||
|
;; Consumers reference an artifact by its CID. Per design §3.4.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "SXArtifact"
|
||||||
|
:doc "Published SX source. :source carries the form text;\n :language is optional ('sx' by default); :imports lists\n CIDs the artifact depends on."
|
||||||
|
:schema (fn (obj) (string? (-> obj :source))))
|
||||||
9
next/genesis/object-types/tombstone.sx
Normal file
9
next/genesis/object-types/tombstone.sx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
;; next/genesis/object-types/tombstone.sx
|
||||||
|
;;
|
||||||
|
;; Replacement for an object that has been Delete'd. Lets projection
|
||||||
|
;; folds keep a marker without retaining the deleted content.
|
||||||
|
|
||||||
|
(DefineObject
|
||||||
|
:name "Tombstone"
|
||||||
|
:doc "Marker for a deleted object. :former-cid carries the CID\n of the object that was removed. Projections fold Tombstone\n by replacing the cached entry (not by omitting it)."
|
||||||
|
:schema (fn (obj) (string? (-> obj :former-cid))))
|
||||||
11
next/genesis/projections/activity-log.sx
Normal file
11
next/genesis/projections/activity-log.sx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
;; next/genesis/projections/activity-log.sx
|
||||||
|
;;
|
||||||
|
;; Identity projection: stores every activity by its CID. The
|
||||||
|
;; base ledger every other projection could be re-derived from
|
||||||
|
;; if needed. Per design §10.2.
|
||||||
|
|
||||||
|
(DefineProjection
|
||||||
|
:name "activity-log"
|
||||||
|
:doc "Maps activity CID to the full envelope. Every activity\n flows through; no filter. State is the CID-keyed dict."
|
||||||
|
:initial-state {}
|
||||||
|
:fold (fn (state act) (assoc state (-> act :cid) act)))
|
||||||
26
next/genesis/projections/actor-state.sx
Normal file
26
next/genesis/projections/actor-state.sx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
;; next/genesis/projections/actor-state.sx
|
||||||
|
;;
|
||||||
|
;; Per-actor live state: publicKeys (with history per design §9.6),
|
||||||
|
;; profile fields (preferredUsername, summary, ...), follower/
|
||||||
|
;; following counts. Powers the actor doc endpoint and the
|
||||||
|
;; time-aware signature verification in envelope:verify_signature/2.
|
||||||
|
|
||||||
|
(DefineProjection
|
||||||
|
:name "actor-state"
|
||||||
|
:doc "Actor-id -> {publicKeys, profile, followers, following}.\n Updated by Create{Person|Service|Group}, Update (key\n rotation, profile edits), Move (federation migration)."
|
||||||
|
:initial-state {}
|
||||||
|
:fold (fn
|
||||||
|
(state act)
|
||||||
|
(let
|
||||||
|
((aid (-> act :actor)) (t (-> act :type)))
|
||||||
|
(cond
|
||||||
|
(= t "Create")
|
||||||
|
(assoc state aid (or (-> act :object) {}))
|
||||||
|
(= t "Update")
|
||||||
|
(assoc
|
||||||
|
state
|
||||||
|
aid
|
||||||
|
(merge
|
||||||
|
(or (get state aid) {})
|
||||||
|
(or (-> act :patch) {})))
|
||||||
|
:else state))))
|
||||||
25
next/genesis/projections/audience-graph.sx
Normal file
25
next/genesis/projections/audience-graph.sx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
;; next/genesis/projections/audience-graph.sx
|
||||||
|
;;
|
||||||
|
;; Per-actor follow / follower graph and audience caches. Folded
|
||||||
|
;; from Follow / Accept / Reject / Undo{Follow}. Used by the
|
||||||
|
;; activity router to expand :to / :cc audiences (Public,
|
||||||
|
;; Followers, Direct) into concrete recipient sets. Per design §16.
|
||||||
|
|
||||||
|
(DefineProjection
|
||||||
|
:name "audience-graph"
|
||||||
|
:doc "Actor-id -> {following, followers, pending} sets.\n Updated by Follow / Accept / Reject / Undo. Federation\n (m2) wires this projection to the delivery queue."
|
||||||
|
:initial-state {}
|
||||||
|
:fold (fn
|
||||||
|
(state act)
|
||||||
|
(let
|
||||||
|
((t (-> act :type)))
|
||||||
|
(cond
|
||||||
|
(= t "Follow")
|
||||||
|
state
|
||||||
|
(= t "Accept")
|
||||||
|
state
|
||||||
|
(= t "Reject")
|
||||||
|
state
|
||||||
|
(= t "Undo")
|
||||||
|
state
|
||||||
|
:else state))))
|
||||||
15
next/genesis/projections/by-actor.sx
Normal file
15
next/genesis/projections/by-actor.sx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
;; next/genesis/projections/by-actor.sx
|
||||||
|
;;
|
||||||
|
;; Index of activity CIDs grouped by :actor. Maps actor-id to a
|
||||||
|
;; list of CIDs in append order. Powers the per-actor outbox
|
||||||
|
;; listing (Step 8) without re-scanning the full log.
|
||||||
|
|
||||||
|
(DefineProjection
|
||||||
|
:name "by-actor"
|
||||||
|
:doc "Actor-id -> list of activity CIDs (append order)."
|
||||||
|
:initial-state {}
|
||||||
|
:fold (fn
|
||||||
|
(state act)
|
||||||
|
(let
|
||||||
|
((a (-> act :actor)) (cid (-> act :cid)))
|
||||||
|
(assoc state a (append (or (get state a) (list)) (list cid))))))
|
||||||
22
next/genesis/projections/by-object.sx
Normal file
22
next/genesis/projections/by-object.sx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
;; next/genesis/projections/by-object.sx
|
||||||
|
;;
|
||||||
|
;; Index of activities that reference each :object CID. Maps
|
||||||
|
;; object-CID to the list of activity CIDs that target it
|
||||||
|
;; (Update / Delete / Announce / etc.). Used for "show me
|
||||||
|
;; everything that happened to X" queries.
|
||||||
|
|
||||||
|
(DefineProjection
|
||||||
|
:name "by-object"
|
||||||
|
:doc "Object CID -> list of activity CIDs that target it."
|
||||||
|
:initial-state {}
|
||||||
|
:fold (fn
|
||||||
|
(state act)
|
||||||
|
(let
|
||||||
|
((obj-cid (-> act :object)) (cid (-> act :cid)))
|
||||||
|
(if
|
||||||
|
(string? obj-cid)
|
||||||
|
(assoc
|
||||||
|
state
|
||||||
|
obj-cid
|
||||||
|
(append (or (get state obj-cid) (list)) (list cid)))
|
||||||
|
state))))
|
||||||
15
next/genesis/projections/by-type.sx
Normal file
15
next/genesis/projections/by-type.sx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
;; next/genesis/projections/by-type.sx
|
||||||
|
;;
|
||||||
|
;; Index of activity CIDs grouped by :type. Maps type-name to a
|
||||||
|
;; list of CIDs in append order. Used by the outbox listing
|
||||||
|
;; endpoints (Step 8) for type-filtered pagination.
|
||||||
|
|
||||||
|
(DefineProjection
|
||||||
|
:name "by-type"
|
||||||
|
:doc "Type-name -> list of activity CIDs (append order)."
|
||||||
|
:initial-state {}
|
||||||
|
:fold (fn
|
||||||
|
(state act)
|
||||||
|
(let
|
||||||
|
((t (-> act :type)) (cid (-> act :cid)))
|
||||||
|
(assoc state t (append (or (get state t) (list)) (list cid))))))
|
||||||
33
next/genesis/projections/define-registry.sx
Normal file
33
next/genesis/projections/define-registry.sx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
;; next/genesis/projections/define-registry.sx
|
||||||
|
;;
|
||||||
|
;; The meta-projection: folds Create{Define*{...}} activities into
|
||||||
|
;; the kernel registry. Resolves the chicken-and-egg circle —
|
||||||
|
;; bootstrap.erl populates the registry directly at startup from
|
||||||
|
;; the genesis bundle, and from then on define-registry's fold
|
||||||
|
;; keeps it current as new Define* activities arrive. Per design §5.
|
||||||
|
|
||||||
|
(DefineProjection
|
||||||
|
:name "define-registry"
|
||||||
|
:doc "Maps {kind, name} -> definition entry. Folded from\n Create{DefineActivity|DefineObject|DefineProjection|\n DefineValidator|DefineCodec|DefineSigSuite|...}. Kind is\n derived from the inner :object :type tag."
|
||||||
|
:initial-state {}
|
||||||
|
:fold (fn
|
||||||
|
(state act)
|
||||||
|
(let
|
||||||
|
((obj (-> act :object)) (otype (-> act :object :type)))
|
||||||
|
(cond
|
||||||
|
(= (-> act :type) "Create")
|
||||||
|
(cond
|
||||||
|
(= otype "DefineActivity")
|
||||||
|
(assoc-in state (list :activity-types (-> obj :name)) obj)
|
||||||
|
(= otype "DefineObject")
|
||||||
|
(assoc-in state (list :object-types (-> obj :name)) obj)
|
||||||
|
(= otype "DefineProjection")
|
||||||
|
(assoc-in state (list :projections (-> obj :name)) obj)
|
||||||
|
(= otype "DefineValidator")
|
||||||
|
(assoc-in state (list :validators (-> obj :name)) obj)
|
||||||
|
(= otype "DefineCodec")
|
||||||
|
(assoc-in state (list :codecs (-> obj :name)) obj)
|
||||||
|
(= otype "DefineSigSuite")
|
||||||
|
(assoc-in state (list :sig-suites (-> obj :name)) obj)
|
||||||
|
:else state)
|
||||||
|
:else state))))
|
||||||
11
next/genesis/sig-suites/ed25519-2020.sx
Normal file
11
next/genesis/sig-suites/ed25519-2020.sx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
;; next/genesis/sig-suites/ed25519-2020.sx
|
||||||
|
;;
|
||||||
|
;; W3C Verifiable Credential signature suite — Ed25519 over
|
||||||
|
;; canonical bytes, key material in multibase. Default suite
|
||||||
|
;; for fed-sx actors per design §9.
|
||||||
|
|
||||||
|
(DefineSigSuite
|
||||||
|
:name "ed25519-2020"
|
||||||
|
:doc "Ed25519 verification. Key carries publicKeyMultibase.\n :verify takes canonical-bytes + signature + key and\n returns bool. Real verification deferred to m2 once\n crypto:verify_ed25519/3 BIF lands; v1 stand-in returns\n false to defer all Ed25519-signed activities."
|
||||||
|
:verify (fn (canonical-bytes signature key) false)
|
||||||
|
:key-format (fn (key-doc) (string? (-> key-doc :publicKeyMultibase))))
|
||||||
11
next/genesis/sig-suites/rsa-sha256-2018.sx
Normal file
11
next/genesis/sig-suites/rsa-sha256-2018.sx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
;; next/genesis/sig-suites/rsa-sha256-2018.sx
|
||||||
|
;;
|
||||||
|
;; W3C Verifiable Credential signature suite — RSA-SHA256 over
|
||||||
|
;; canonical bytes, key material in PEM. Compatible with
|
||||||
|
;; Mastodon's HTTP-Signatures / Linked-Data-Signatures-2017.
|
||||||
|
|
||||||
|
(DefineSigSuite
|
||||||
|
:name "rsa-sha256-2018"
|
||||||
|
:doc "RSA-SHA256 verification. Key carries publicKeyPem.\n :verify takes canonical-bytes + signature + key and\n returns bool. Real verification deferred to m2 once\n crypto:verify_rsa/3 BIF lands; v1 stand-in returns\n false to defer all RSA-signed activities."
|
||||||
|
:verify (fn (canonical-bytes signature key) false)
|
||||||
|
:key-format (fn (key-doc) (string? (-> key-doc :publicKeyPem))))
|
||||||
22
next/genesis/validators/envelope-shape.sx
Normal file
22
next/genesis/validators/envelope-shape.sx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
;; next/genesis/validators/envelope-shape.sx
|
||||||
|
;;
|
||||||
|
;; Validates required envelope fields per design §3.1. Stage 1 of
|
||||||
|
;; the validation pipeline (Step 6). Mirrors the kernel's
|
||||||
|
;; envelope:validate_shape/1 from Step 2a — when the pipeline runs
|
||||||
|
;; in OCaml-side sandbox eval mode it dispatches by name; when it
|
||||||
|
;; runs through the kernel Erlang path it short-circuits to the BIF.
|
||||||
|
|
||||||
|
(DefineValidator
|
||||||
|
:name "envelope-shape"
|
||||||
|
:doc "Required-fields check on the activity envelope:\n :id, :type, :actor, :published, :signature must all be\n present and non-nil. The :signature sub-field needs\n :key_id, :algorithm, :value."
|
||||||
|
:predicate (fn
|
||||||
|
(act)
|
||||||
|
(and
|
||||||
|
(not (nil? (-> act :id)))
|
||||||
|
(not (nil? (-> act :type)))
|
||||||
|
(not (nil? (-> act :actor)))
|
||||||
|
(not (nil? (-> act :published)))
|
||||||
|
(not (nil? (-> act :signature)))
|
||||||
|
(not (nil? (-> act :signature :key_id)))
|
||||||
|
(not (nil? (-> act :signature :algorithm)))
|
||||||
|
(not (nil? (-> act :signature :value))))))
|
||||||
13
next/genesis/validators/signature.sx
Normal file
13
next/genesis/validators/signature.sx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
;; next/genesis/validators/signature.sx
|
||||||
|
;;
|
||||||
|
;; Stage 2 of the validation pipeline per design §14. Verifies the
|
||||||
|
;; activity signature against the time-relevant public key in the
|
||||||
|
;; actor-state projection. Bootstrap entry; the kernel dispatches
|
||||||
|
;; to envelope:verify_signature/2 (Step 2c) when running in
|
||||||
|
;; Erlang-on-SX mode. Per design §9.6 the lookup is timestamp-aware
|
||||||
|
;; — key validity is evaluated at :published, not "now".
|
||||||
|
|
||||||
|
(DefineValidator
|
||||||
|
:name "signature"
|
||||||
|
:doc "Signature verification. Picks the signature suite by\n :signature :algorithm, fetches the key with id ==\n :signature :key_id that was active at :published from\n the actor-state projection, then dispatches to the\n suite's :verify body."
|
||||||
|
:predicate (fn (act) true))
|
||||||
21
next/genesis/validators/type-schema.sx
Normal file
21
next/genesis/validators/type-schema.sx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
;; next/genesis/validators/type-schema.sx
|
||||||
|
;;
|
||||||
|
;; Stage 5 of the validation pipeline per design §14. Validates
|
||||||
|
;; the activity's :object against the schema registered for its
|
||||||
|
;; :object :type in the define-registry projection.
|
||||||
|
|
||||||
|
(DefineValidator
|
||||||
|
:name "type-schema"
|
||||||
|
:doc "Looks up the object-type registration in the\n define-registry projection, fetches its :schema body,\n and evaluates it against (-> act :object). Returns true\n when no object-type is named (some verbs carry no\n :object) or when no schema is registered for the named\n type (open-world default — Step 6 may tighten)."
|
||||||
|
:predicate (fn
|
||||||
|
(act)
|
||||||
|
(let
|
||||||
|
((obj (-> act :object)))
|
||||||
|
(cond
|
||||||
|
(nil? obj)
|
||||||
|
true
|
||||||
|
(nil? (-> obj :type))
|
||||||
|
true
|
||||||
|
:else (let
|
||||||
|
((schema (-> (registry-lookup :object-types (-> obj :type)) :schema)))
|
||||||
|
(if (nil? schema) true (apply-schema schema obj)))))))
|
||||||
0
next/kernel/.gitkeep
Normal file
0
next/kernel/.gitkeep
Normal file
260
next/kernel/actor_state.erl
Normal file
260
next/kernel/actor_state.erl
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
-module(actor_state).
|
||||||
|
-export([fold/2, fold_fn/0, new/0, lookup/2, has/2, actors/1,
|
||||||
|
profile_type/1, profile_name/1, profile_field/2,
|
||||||
|
key_history/1, active_keys_at/2, find_key_by_id/2]).
|
||||||
|
|
||||||
|
%% Actor-state projection fold — Erlang-fun stand-in for the
|
||||||
|
%% genesis `actor-state.sx` projection body. Tracks per-actor
|
||||||
|
%% profiles, key-history, and Move pointers per design §9.1-§9.4.
|
||||||
|
%%
|
||||||
|
%% State shape:
|
||||||
|
%% [{ActorId, Profile}, ...]
|
||||||
|
%%
|
||||||
|
%% Profile = [{type, person|service|group},
|
||||||
|
%% {name, Bin},
|
||||||
|
%% {preferredUsername, Bin},
|
||||||
|
%% {summary, Bin},
|
||||||
|
%% {icon, Bin},
|
||||||
|
%% {public_keys, [Key]},
|
||||||
|
%% {moved_to, ActorIdOrUrl},
|
||||||
|
%% {created, N}]
|
||||||
|
%%
|
||||||
|
%% Bridge note: the SX-source eval bridge would replace this fold
|
||||||
|
%% body once available (same gap as Step 5d-pure / Step 6c-schema-pure).
|
||||||
|
%% define_registry.erl is the structural twin.
|
||||||
|
%%
|
||||||
|
%% lists:keyfind/keymember aren't in this substrate (Step 1a noted
|
||||||
|
%% same gap), so local `find_keyed`/`has_keyed`/`set_keyed` helpers
|
||||||
|
%% handle the keyed-list ops.
|
||||||
|
|
||||||
|
new() -> [].
|
||||||
|
|
||||||
|
actors(State) -> [Id || {Id, _Profile} <- State].
|
||||||
|
|
||||||
|
has(ActorId, State) -> has_keyed(ActorId, State).
|
||||||
|
|
||||||
|
lookup(ActorId, State) ->
|
||||||
|
case find_keyed(ActorId, State) of
|
||||||
|
{ok, Profile} -> {ok, Profile};
|
||||||
|
{error, _} -> not_found
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── Fold dispatch ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
fold(Activity, State) ->
|
||||||
|
case envelope:get_field(type, Activity) of
|
||||||
|
{ok, create} -> fold_create(Activity, State);
|
||||||
|
{ok, update} -> fold_update(Activity, State);
|
||||||
|
{ok, move} -> fold_move(Activity, State);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_create(Activity, State) ->
|
||||||
|
case envelope:get_field(object, Activity) of
|
||||||
|
{ok, Obj} ->
|
||||||
|
case envelope:get_field(type, Obj) of
|
||||||
|
{ok, ObjType} ->
|
||||||
|
case is_actor_type(ObjType) of
|
||||||
|
true -> register_actor(Activity, Obj, ObjType, State);
|
||||||
|
false -> State
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
register_actor(Activity, Obj, ObjType, State) ->
|
||||||
|
case envelope:get_field(actor, Activity) of
|
||||||
|
{ok, ActorId} ->
|
||||||
|
case has_keyed(ActorId, State) of
|
||||||
|
true ->
|
||||||
|
State;
|
||||||
|
false ->
|
||||||
|
Created = published_seq(Activity),
|
||||||
|
Profile = build_profile(ObjType, Obj, Created),
|
||||||
|
State ++ [{ActorId, Profile}]
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_update(Activity, State) ->
|
||||||
|
case envelope:get_field(actor, Activity) of
|
||||||
|
{ok, ActorId} ->
|
||||||
|
case find_keyed(ActorId, State) of
|
||||||
|
{ok, Profile} ->
|
||||||
|
case envelope:get_field(patch, Activity) of
|
||||||
|
{ok, Patch} ->
|
||||||
|
Published = published_seq(Activity),
|
||||||
|
NewProfile = apply_patch(Profile, Patch, Published),
|
||||||
|
set_keyed(ActorId, NewProfile, State);
|
||||||
|
_ -> State
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_move(Activity, State) ->
|
||||||
|
case envelope:get_field(actor, Activity) of
|
||||||
|
{ok, ActorId} ->
|
||||||
|
case find_keyed(ActorId, State) of
|
||||||
|
{ok, Profile} ->
|
||||||
|
case envelope:get_field(moved_to, Activity) of
|
||||||
|
{ok, Target} ->
|
||||||
|
NewProfile = set_keyed(moved_to, Target, Profile),
|
||||||
|
set_keyed(ActorId, NewProfile, State);
|
||||||
|
_ -> State
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── Profile assembly ────────────────────────────────────────────
|
||||||
|
|
||||||
|
build_profile(ObjType, Obj, Created) ->
|
||||||
|
Base = [{type, ObjType}, {created, Created}],
|
||||||
|
Fields = [name, preferredUsername, summary, icon, public_keys],
|
||||||
|
Base ++ collect_fields(Fields, Obj).
|
||||||
|
|
||||||
|
collect_fields([], _) -> [];
|
||||||
|
collect_fields([F | Rest], Obj) ->
|
||||||
|
case envelope:get_field(F, Obj) of
|
||||||
|
{ok, V} -> [{F, V} | collect_fields(Rest, Obj)];
|
||||||
|
_ -> collect_fields(Rest, Obj)
|
||||||
|
end.
|
||||||
|
|
||||||
|
merge_patch(Profile, []) -> Profile;
|
||||||
|
merge_patch(Profile, [{K, V} | Rest]) ->
|
||||||
|
merge_patch(set_keyed(K, V, Profile), Rest);
|
||||||
|
merge_patch(Profile, _) -> Profile.
|
||||||
|
|
||||||
|
%% apply_patch/3 — same as merge_patch but special-cases two
|
||||||
|
%% key-rotation patch entries per design §9.6:
|
||||||
|
%% {add_publicKey, KeyProplist} — append a new key to :public_keys,
|
||||||
|
%% defaulting :created to Published.
|
||||||
|
%% {supersede, OldKeyId} — mark the key with :id =:= OldKeyId
|
||||||
|
%% as :superseded_at = Published.
|
||||||
|
%% Other patch entries fall through to last-write-wins per key.
|
||||||
|
|
||||||
|
apply_patch(Profile, [], _Published) -> Profile;
|
||||||
|
apply_patch(Profile, [{add_publicKey, NewKey} | Rest], Published) ->
|
||||||
|
Augmented = ensure_created(NewKey, Published),
|
||||||
|
Current = current_public_keys(Profile),
|
||||||
|
NewKeys = Current ++ [Augmented],
|
||||||
|
apply_patch(set_keyed(public_keys, NewKeys, Profile), Rest, Published);
|
||||||
|
apply_patch(Profile, [{supersede, OldKeyId} | Rest], Published) ->
|
||||||
|
Current = current_public_keys(Profile),
|
||||||
|
NewKeys = mark_superseded(OldKeyId, Published, Current),
|
||||||
|
apply_patch(set_keyed(public_keys, NewKeys, Profile), Rest, Published);
|
||||||
|
apply_patch(Profile, [{K, V} | Rest], Published) ->
|
||||||
|
apply_patch(set_keyed(K, V, Profile), Rest, Published);
|
||||||
|
apply_patch(Profile, _, _) -> Profile.
|
||||||
|
|
||||||
|
current_public_keys(Profile) ->
|
||||||
|
case find_keyed(public_keys, Profile) of
|
||||||
|
{ok, Keys} -> Keys;
|
||||||
|
_ -> []
|
||||||
|
end.
|
||||||
|
|
||||||
|
ensure_created(Key, Published) ->
|
||||||
|
case find_keyed(created, Key) of
|
||||||
|
{ok, _} -> Key;
|
||||||
|
_ -> set_keyed(created, Published, Key)
|
||||||
|
end.
|
||||||
|
|
||||||
|
mark_superseded(_, _, []) -> [];
|
||||||
|
mark_superseded(OldId, At, [Key | Rest]) ->
|
||||||
|
case find_keyed(id, Key) of
|
||||||
|
{ok, OldId} ->
|
||||||
|
case find_keyed(superseded_at, Key) of
|
||||||
|
{ok, _} -> [Key | mark_superseded(OldId, At, Rest)];
|
||||||
|
_ -> [set_keyed(superseded_at, At, Key) | mark_superseded(OldId, At, Rest)]
|
||||||
|
end;
|
||||||
|
_ -> [Key | mark_superseded(OldId, At, Rest)]
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Key-history view — full :public_keys list including superseded
|
||||||
|
%% entries (per §9.6: history is preserved so historical activities
|
||||||
|
%% verify against keys that were active at their :published time).
|
||||||
|
|
||||||
|
key_history(Profile) ->
|
||||||
|
current_public_keys(Profile).
|
||||||
|
|
||||||
|
%% active_keys_at/2 — the subset of :public_keys active at Now,
|
||||||
|
%% mirroring envelope's is_active_at semantics (local copy: envelope
|
||||||
|
%% keeps the predicate private).
|
||||||
|
|
||||||
|
active_keys_at(Profile, Now) ->
|
||||||
|
[K || K <- current_public_keys(Profile),
|
||||||
|
key_active_at(K, Now)].
|
||||||
|
|
||||||
|
find_key_by_id(KeyId, Profile) ->
|
||||||
|
find_key_by_id_in(KeyId, current_public_keys(Profile)).
|
||||||
|
|
||||||
|
find_key_by_id_in(_, []) -> not_found;
|
||||||
|
find_key_by_id_in(WantId, [K | Rest]) ->
|
||||||
|
case find_keyed(id, K) of
|
||||||
|
{ok, WantId} -> {ok, K};
|
||||||
|
_ -> find_key_by_id_in(WantId, Rest)
|
||||||
|
end.
|
||||||
|
|
||||||
|
key_active_at(Key, Now) ->
|
||||||
|
case find_keyed(created, Key) of
|
||||||
|
{ok, Created} when Now >= Created ->
|
||||||
|
case find_keyed(superseded_at, Key) of
|
||||||
|
{ok, SupAt} -> Now < SupAt;
|
||||||
|
_ -> true
|
||||||
|
end;
|
||||||
|
_ -> false
|
||||||
|
end.
|
||||||
|
|
||||||
|
published_seq(Activity) ->
|
||||||
|
case envelope:get_field(published, Activity) of
|
||||||
|
{ok, P} -> P;
|
||||||
|
_ -> 0
|
||||||
|
end.
|
||||||
|
|
||||||
|
is_actor_type(person) -> true;
|
||||||
|
is_actor_type(service) -> true;
|
||||||
|
is_actor_type(group) -> true;
|
||||||
|
is_actor_type(_) -> false.
|
||||||
|
|
||||||
|
%% ── Profile accessors ───────────────────────────────────────────
|
||||||
|
|
||||||
|
profile_type(Profile) ->
|
||||||
|
case find_keyed(type, Profile) of
|
||||||
|
{ok, T} -> T;
|
||||||
|
_ -> nil
|
||||||
|
end.
|
||||||
|
|
||||||
|
profile_name(Profile) ->
|
||||||
|
case find_keyed(name, Profile) of
|
||||||
|
{ok, N} -> N;
|
||||||
|
_ -> nil
|
||||||
|
end.
|
||||||
|
|
||||||
|
profile_field(F, Profile) ->
|
||||||
|
case find_keyed(F, Profile) of
|
||||||
|
{ok, V} -> {ok, V};
|
||||||
|
_ -> not_found
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── Projection integration ──────────────────────────────────────
|
||||||
|
|
||||||
|
fold_fn() ->
|
||||||
|
fun (Activity, State) -> fold(Activity, State) end.
|
||||||
|
|
||||||
|
%% ── Internal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
has_keyed(_, []) -> false;
|
||||||
|
has_keyed(K, [{K, _} | _]) -> true;
|
||||||
|
has_keyed(K, [_ | Rest]) -> has_keyed(K, Rest).
|
||||||
|
|
||||||
|
find_keyed(_, []) -> {error, not_found};
|
||||||
|
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||||
|
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||||
|
|
||||||
|
set_keyed(K, V, []) -> [{K, V}];
|
||||||
|
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||||
79
next/kernel/announce_state.erl
Normal file
79
next/kernel/announce_state.erl
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
-module(announce_state).
|
||||||
|
-export([new/0, fold/2, fold_fn/0,
|
||||||
|
announcers_for/2, announce_count/2, announced_cids/1,
|
||||||
|
has_announced/3]).
|
||||||
|
|
||||||
|
%% Announce-fanout projection. Folds Announce activities into a
|
||||||
|
%% per-target-Cid set of announcer ActorIds so projections can
|
||||||
|
%% answer "who re-broadcast this activity" / "how many announces
|
||||||
|
%% does this Note have" / "what activities has X announced".
|
||||||
|
%%
|
||||||
|
%% Announce envelope shape (per next/genesis/activity-types/announce.sx):
|
||||||
|
%% [{type, announce},
|
||||||
|
%% {actor, AnnouncerActorId},
|
||||||
|
%% {object, TargetCidBinary},
|
||||||
|
%% ...]
|
||||||
|
%%
|
||||||
|
%% State shape:
|
||||||
|
%% [{TargetCid, [Announcer1, Announcer2, ...]}, ...]
|
||||||
|
%%
|
||||||
|
%% Set semantics — the same actor announcing the same target twice
|
||||||
|
%% is a no-op (already in the list). Undo{Announce} retraction
|
||||||
|
%% defers to a follow-up.
|
||||||
|
|
||||||
|
new() -> [].
|
||||||
|
|
||||||
|
fold_fn() ->
|
||||||
|
fun (Activity, State) -> fold(Activity, State) end.
|
||||||
|
|
||||||
|
fold(Activity, State) ->
|
||||||
|
case envelope:get_field(type, Activity) of
|
||||||
|
{ok, announce} -> fold_announce(Activity, State);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_announce(Activity, State) ->
|
||||||
|
case {envelope:get_field(actor, Activity),
|
||||||
|
envelope:get_field(object, Activity)} of
|
||||||
|
{{ok, Actor}, {ok, Cid}} -> add_announcer(Cid, Actor, State);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
add_announcer(Cid, Actor, State) ->
|
||||||
|
Current = case find_keyed(Cid, State) of
|
||||||
|
{ok, Set} -> Set;
|
||||||
|
_ -> []
|
||||||
|
end,
|
||||||
|
case contains(Actor, Current) of
|
||||||
|
true -> State;
|
||||||
|
false -> set_keyed(Cid, Current ++ [Actor], State)
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── Read-side accessors ───────────────────────────────────────
|
||||||
|
|
||||||
|
announcers_for(Cid, State) ->
|
||||||
|
case find_keyed(Cid, State) of
|
||||||
|
{ok, Set} -> Set;
|
||||||
|
_ -> []
|
||||||
|
end.
|
||||||
|
|
||||||
|
announce_count(Cid, State) -> length(announcers_for(Cid, State)).
|
||||||
|
|
||||||
|
announced_cids(State) -> [C || {C, _} <- State].
|
||||||
|
|
||||||
|
has_announced(Actor, Cid, State) ->
|
||||||
|
contains(Actor, announcers_for(Cid, State)).
|
||||||
|
|
||||||
|
%% ── Internal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
contains(_, []) -> false;
|
||||||
|
contains(X, [X | _]) -> true;
|
||||||
|
contains(X, [_ | Rest]) -> contains(X, Rest).
|
||||||
|
|
||||||
|
find_keyed(_, []) -> {error, not_found};
|
||||||
|
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||||
|
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||||
|
|
||||||
|
set_keyed(K, V, []) -> [{K, V}];
|
||||||
|
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||||
136
next/kernel/backfill.erl
Normal file
136
next/kernel/backfill.erl
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
-module(backfill).
|
||||||
|
-export([slice/2, slice/3,
|
||||||
|
wrap_backfill/1, parse_mode/1,
|
||||||
|
all_entries/1, last_n_entries/2, last_t_entries/3,
|
||||||
|
since_cid_entries/2, none_entries/0]).
|
||||||
|
|
||||||
|
%% Backfill mode slicing per design §13.3 / Step 9. When A follows B
|
||||||
|
%% with a backfill spec, B's kernel slices the outbox log into the
|
||||||
|
%% appropriate window and delivers each entry as
|
||||||
|
%% `{backfilled, true}`-marked envelopes alongside forward-going
|
||||||
|
%% activity.
|
||||||
|
%%
|
||||||
|
%% Mode shapes (per the Follow activity's `:backfill` field):
|
||||||
|
%% none — newer follower sees only forward content
|
||||||
|
%% {last_n, N} — backfill last N activities (FIFO order)
|
||||||
|
%% {last_t, T, NowFn} — backfill activities with :published in
|
||||||
|
%% (Now - T .. Now]. NowFn is a 0-arity fun
|
||||||
|
%% so tests can fake-time it.
|
||||||
|
%% full — backfill the entire outbox
|
||||||
|
%%
|
||||||
|
%% slice/2 returns the activity list. slice/3 also wraps each entry
|
||||||
|
%% with `{backfilled, true}` so projections can decide whether to
|
||||||
|
%% re-fold or skip (the §13.3 Backfilled bodies preserve the
|
||||||
|
%% original `:id` so replay defence still works on the receiver).
|
||||||
|
%%
|
||||||
|
%% parse_mode/1 lifts the Follow activity's `:backfill` proplist
|
||||||
|
%% (or atom) into the internal mode tuple. Unknown shapes fall back
|
||||||
|
%% to `none` — the default open-world policy.
|
||||||
|
|
||||||
|
slice(Mode, LogState) ->
|
||||||
|
slice(Mode, LogState, false).
|
||||||
|
|
||||||
|
slice(Mode, LogState, Wrap) ->
|
||||||
|
Entries = log:entries(LogState),
|
||||||
|
Slice = case Mode of
|
||||||
|
none -> none_entries();
|
||||||
|
full -> all_entries(Entries);
|
||||||
|
{last_n, N} -> last_n_entries(N, Entries);
|
||||||
|
{last_t, T, NowFn} -> last_t_entries(T, NowFn, Entries);
|
||||||
|
{since_cid, Cid} -> since_cid_entries(Cid, Entries);
|
||||||
|
_ -> none_entries()
|
||||||
|
end,
|
||||||
|
case Wrap of
|
||||||
|
true -> wrap_backfill(Slice);
|
||||||
|
_ -> Slice
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── Mode-specific entry selection ─────────────────────────────
|
||||||
|
|
||||||
|
all_entries(Entries) -> Entries.
|
||||||
|
|
||||||
|
none_entries() -> [].
|
||||||
|
|
||||||
|
%% last_n_entries/2 — tail N entries in FIFO order.
|
||||||
|
|
||||||
|
last_n_entries(N, _) when N =< 0 -> [];
|
||||||
|
last_n_entries(N, Entries) ->
|
||||||
|
Len = length(Entries),
|
||||||
|
case Len =< N of
|
||||||
|
true -> Entries;
|
||||||
|
false -> drop_n(Len - N, Entries)
|
||||||
|
end.
|
||||||
|
|
||||||
|
drop_n(0, L) -> L;
|
||||||
|
drop_n(_, []) -> [];
|
||||||
|
drop_n(N, [_ | Rest]) -> drop_n(N - 1, Rest).
|
||||||
|
|
||||||
|
%% last_t_entries/3 — entries whose :published is within the last
|
||||||
|
%% T units of (NowFn() - T .. NowFn()]. T and :published are
|
||||||
|
%% integers (seconds-since-epoch in production; opaque ints in tests).
|
||||||
|
|
||||||
|
last_t_entries(T, NowFn, Entries) when is_integer(T), T >= 0 ->
|
||||||
|
Now = NowFn(),
|
||||||
|
Cutoff = Now - T,
|
||||||
|
[E || E <- Entries, in_window(E, Cutoff, Now)];
|
||||||
|
last_t_entries(_, _, _) -> [].
|
||||||
|
|
||||||
|
in_window(Activity, Cutoff, Now) ->
|
||||||
|
case envelope:get_field(published, Activity) of
|
||||||
|
{ok, P} when is_integer(P), P > Cutoff, P =< Now -> true;
|
||||||
|
_ -> false
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% since_cid_entries/2 — every entry after the one with :id = Cid.
|
||||||
|
%% If Cid isn't in the log, returns [] (caller's pointer is stale).
|
||||||
|
%% Used by `GET /actors/<id>/outbox?since=Cid` pagination.
|
||||||
|
|
||||||
|
since_cid_entries(_Cid, []) -> [];
|
||||||
|
since_cid_entries(Cid, [E | Rest]) ->
|
||||||
|
case envelope:get_field(id, E) of
|
||||||
|
{ok, Cid} -> Rest;
|
||||||
|
_ -> since_cid_entries(Cid, Rest)
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% wrap_backfill/1 — append `{backfilled, true}` to each entry.
|
||||||
|
%% The receiving projection scheduler reads this field and chooses
|
||||||
|
%% whether to fold (re-emit) or skip (already known via replay
|
||||||
|
%% defence on `:id`).
|
||||||
|
|
||||||
|
wrap_backfill([]) -> [];
|
||||||
|
wrap_backfill([E | Rest]) ->
|
||||||
|
[E ++ [{backfilled, true}] | wrap_backfill(Rest)].
|
||||||
|
|
||||||
|
%% parse_mode/1 — Lift a Follow activity's `:backfill` value into the
|
||||||
|
%% internal mode tuple. Accepts:
|
||||||
|
%% nil / not_found -> none
|
||||||
|
%% none -> none
|
||||||
|
%% full -> full
|
||||||
|
%% {last_n, N} -> {last_n, N} (already-parsed shape)
|
||||||
|
%% {last_t, T, NowFn} -> pass-through
|
||||||
|
%% Proplist with :mode + :limit / :duration -> parsed
|
||||||
|
%% Unknown shape -> none (open-world default).
|
||||||
|
|
||||||
|
parse_mode(nil) -> none;
|
||||||
|
parse_mode(none) -> none;
|
||||||
|
parse_mode(full) -> full;
|
||||||
|
parse_mode({last_n, N}) -> {last_n, N};
|
||||||
|
parse_mode({last_t, T, NowFn}) -> {last_t, T, NowFn};
|
||||||
|
parse_mode({since_cid, Cid}) -> {since_cid, Cid};
|
||||||
|
parse_mode(List) when is_list(List) ->
|
||||||
|
case envelope:get_field(mode, List) of
|
||||||
|
{ok, last_n} ->
|
||||||
|
case envelope:get_field(limit, List) of
|
||||||
|
{ok, N} when is_integer(N) -> {last_n, N};
|
||||||
|
_ -> none
|
||||||
|
end;
|
||||||
|
{ok, last_t} ->
|
||||||
|
case envelope:get_field(duration, List) of
|
||||||
|
{ok, T} when is_integer(T) -> {last_t, T, fun () -> 0 end};
|
||||||
|
_ -> none
|
||||||
|
end;
|
||||||
|
{ok, full} -> full;
|
||||||
|
{ok, none} -> none;
|
||||||
|
_ -> none
|
||||||
|
end;
|
||||||
|
parse_mode(_) -> none.
|
||||||
223
next/kernel/bootstrap.erl
Normal file
223
next/kernel/bootstrap.erl
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
-module(bootstrap).
|
||||||
|
-export([read_genesis/0, read_genesis/1,
|
||||||
|
read_section/2, sections/0, section_subdir/1,
|
||||||
|
default_base/0, ends_with_sx/1,
|
||||||
|
build_genesis/1, verify_genesis/2,
|
||||||
|
cidhash_path/1, write_cidhash/2, read_cidhash/1,
|
||||||
|
load_genesis/1, strip_sx_suffix/1,
|
||||||
|
populate_registry/0,
|
||||||
|
start/3]).
|
||||||
|
|
||||||
|
%% Genesis bundle reader per design §12.2.
|
||||||
|
%%
|
||||||
|
%% read_genesis/0,1 walks the seven canonical section subdirectories
|
||||||
|
%% under `next/genesis/`, filters .sx files, reads each file into a
|
||||||
|
%% binary, and returns a structured snapshot:
|
||||||
|
%%
|
||||||
|
%% {ok, [{Section :: atom,
|
||||||
|
%% [{FileName :: binary, FileBytes :: binary}, ...]},
|
||||||
|
%% ...]}
|
||||||
|
%%
|
||||||
|
%% Step 4d will compute the bundle CID by hashing the assembled
|
||||||
|
%% byte string across all entries; Step 4e will register the parsed
|
||||||
|
%% definitions in the kernel registry.
|
||||||
|
%%
|
||||||
|
%% Port note: this module does NOT parse the .sx contents. The
|
||||||
|
%% Erlang-on-SX port has no in-Erlang path from binary bytes to SX
|
||||||
|
%% structured terms (same substrate gap that parked Step 3b); the
|
||||||
|
%% bundle CID needs only the raw bytes, and registry registration
|
||||||
|
%% will happen via an SX-side helper that the kernel hands the
|
||||||
|
%% binary contents to. read_genesis/1 ignores its arg in v1 except
|
||||||
|
%% to swap the BasePath — `default_base/0` is "next/genesis".
|
||||||
|
%%
|
||||||
|
%% Port note 2: string-literal binary segments `<<"abc">>` truncate
|
||||||
|
%% to one byte in this port, so all path constants are hand-spelled
|
||||||
|
%% as integer-segment binaries.
|
||||||
|
|
||||||
|
%% ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
%% "next/genesis"
|
||||||
|
default_base() ->
|
||||||
|
<<110,101,120,116,47,103,101,110,101,115,105,115>>.
|
||||||
|
|
||||||
|
read_genesis() ->
|
||||||
|
read_genesis(default_base()).
|
||||||
|
|
||||||
|
read_genesis(BasePath) ->
|
||||||
|
{ok, lists:map(
|
||||||
|
fun (S) -> {S, read_section(BasePath, S)} end,
|
||||||
|
sections())}.
|
||||||
|
|
||||||
|
sections() ->
|
||||||
|
[activity_types, object_types, projections,
|
||||||
|
validators, codecs, sig_suites, audience].
|
||||||
|
|
||||||
|
%% "activity-types"
|
||||||
|
section_subdir(activity_types) ->
|
||||||
|
<<97,99,116,105,118,105,116,121,45,116,121,112,101,115>>;
|
||||||
|
%% "object-types"
|
||||||
|
section_subdir(object_types) ->
|
||||||
|
<<111,98,106,101,99,116,45,116,121,112,101,115>>;
|
||||||
|
%% "projections"
|
||||||
|
section_subdir(projections) ->
|
||||||
|
<<112,114,111,106,101,99,116,105,111,110,115>>;
|
||||||
|
%% "validators"
|
||||||
|
section_subdir(validators) ->
|
||||||
|
<<118,97,108,105,100,97,116,111,114,115>>;
|
||||||
|
%% "codecs"
|
||||||
|
section_subdir(codecs) ->
|
||||||
|
<<99,111,100,101,99,115>>;
|
||||||
|
%% "sig-suites"
|
||||||
|
section_subdir(sig_suites) ->
|
||||||
|
<<115,105,103,45,115,117,105,116,101,115>>;
|
||||||
|
%% "audience"
|
||||||
|
section_subdir(audience) ->
|
||||||
|
<<97,117,100,105,101,110,99,101>>.
|
||||||
|
|
||||||
|
read_section(BasePath, Section) ->
|
||||||
|
SubDir = section_subdir(Section),
|
||||||
|
%% 47 = '/'
|
||||||
|
Path = <<BasePath/binary, 47, SubDir/binary>>,
|
||||||
|
case file:list_dir(Path) of
|
||||||
|
{ok, Names} ->
|
||||||
|
SxNames = lists:filter(fun (N) -> ends_with_sx(N) end, Names),
|
||||||
|
lists:map(fun (Name) -> read_one(Path, Name) end, SxNames);
|
||||||
|
{error, _} ->
|
||||||
|
[]
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Suffix check on the .sx extension. 46='.' 115='s' 120='x'.
|
||||||
|
ends_with_sx(<<46, 115, 120>>) -> true;
|
||||||
|
ends_with_sx(<<>>) -> false;
|
||||||
|
ends_with_sx(<<_, Rest/binary>>) -> ends_with_sx(Rest).
|
||||||
|
|
||||||
|
%% ── Internal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
read_one(DirPath, Name) ->
|
||||||
|
Full = <<DirPath/binary, 47, Name/binary>>,
|
||||||
|
case file:read_file(Full) of
|
||||||
|
{ok, Bytes} -> {Name, Bytes};
|
||||||
|
{error, R} -> {Name, {error, R}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── Step 4d: bundle CID compute + verify ────────────────────────
|
||||||
|
%%
|
||||||
|
%% The bundle CID is the canonical content-address of everything in
|
||||||
|
%% read_genesis/0's result. We delegate to the host `cid:to_string/1`
|
||||||
|
%% BIF (Step 1b substrate): it walks the term via `er-format-value`,
|
||||||
|
%% feeds the deterministic textual form into `cid-from-sx`, returns
|
||||||
|
%% a CIDv1 (raw codec, sha2-256 multihash) as a binary.
|
||||||
|
%%
|
||||||
|
%% Design §12.3: at startup the kernel computes this CID and
|
||||||
|
%% compares against a hardcoded value (here: a sibling `.cidhash`
|
||||||
|
%% file). A mismatch is a hard refuse-to-start.
|
||||||
|
|
||||||
|
build_genesis(ReadResult) ->
|
||||||
|
case ReadResult of
|
||||||
|
{ok, Sections} ->
|
||||||
|
Cid = cid:to_string({genesis_bundle, Sections}),
|
||||||
|
{ok, [{cid, Cid}, {sections, Sections}]};
|
||||||
|
Other ->
|
||||||
|
{error, {bad_read_result, Other}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
verify_genesis(ReadResult, ExpectedCid) ->
|
||||||
|
case build_genesis(ReadResult) of
|
||||||
|
{ok, [{cid, Cid}, _]} ->
|
||||||
|
case Cid =:= ExpectedCid of
|
||||||
|
true -> ok;
|
||||||
|
false -> {error, {cid_mismatch, Cid, ExpectedCid}}
|
||||||
|
end;
|
||||||
|
Err -> Err
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Sibling-file CID storage. "/.cidhash" appended to BasePath as
|
||||||
|
%% an integer-segment binary (string-literal segments are broken).
|
||||||
|
|
||||||
|
%% "/.cidhash" — 47='/' 46='.' c i d h a s h
|
||||||
|
cidhash_path(BasePath) ->
|
||||||
|
<<BasePath/binary, 47, 46, 99, 105, 100, 104, 97, 115, 104>>.
|
||||||
|
|
||||||
|
write_cidhash(BasePath, Cid) ->
|
||||||
|
file:write_file(cidhash_path(BasePath), Cid).
|
||||||
|
|
||||||
|
read_cidhash(BasePath) ->
|
||||||
|
file:read_file(cidhash_path(BasePath)).
|
||||||
|
|
||||||
|
%% ── Step 4e: load_genesis → registry ────────────────────────────
|
||||||
|
%%
|
||||||
|
%% Walks the read_genesis result and registers each file as a
|
||||||
|
%% registry entry. The section atom is the registry kind directly
|
||||||
|
%% (both name spaces are identical — see Step 4c sections/0 and
|
||||||
|
%% Step 5a registry:kinds/0). The entry Name is the filename minus
|
||||||
|
%% the `.sx` suffix, kept as a binary; the entry value is the
|
||||||
|
%% file's raw bytes.
|
||||||
|
%%
|
||||||
|
%% Returns `{ok, RegistryState}` on success. Later steps (4f / the
|
||||||
|
%% SX-parser bridge) will replace the raw bytes with parsed forms;
|
||||||
|
%% the binary stand-in is enough to prove the bridge works.
|
||||||
|
|
||||||
|
load_genesis(ReadResult) ->
|
||||||
|
case ReadResult of
|
||||||
|
{ok, Sections} ->
|
||||||
|
{ok, load_sections(Sections, registry:new())};
|
||||||
|
Other ->
|
||||||
|
{error, {bad_read_result, Other}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
load_sections([], State) -> State;
|
||||||
|
load_sections([{Kind, Entries} | Rest], State) ->
|
||||||
|
load_sections(Rest, load_entries(Kind, Entries, State)).
|
||||||
|
|
||||||
|
load_entries(_Kind, [], State) -> State;
|
||||||
|
load_entries(Kind, [{Name, Bytes} | Rest], State) ->
|
||||||
|
BaseName = strip_sx_suffix(Name),
|
||||||
|
{ok, NewState} = registry:register(Kind, BaseName, Bytes, State),
|
||||||
|
load_entries(Kind, Rest, NewState).
|
||||||
|
|
||||||
|
%% strip_sx_suffix(Binary) — drops the trailing ".sx" if present.
|
||||||
|
%% 46='.' 115='s' 120='x'.
|
||||||
|
strip_sx_suffix(B) when is_binary(B) ->
|
||||||
|
case ends_with_sx(B) of
|
||||||
|
false -> B;
|
||||||
|
true -> take_prefix(B, byte_size(B) - 3)
|
||||||
|
end.
|
||||||
|
|
||||||
|
take_prefix(_, 0) -> <<>>;
|
||||||
|
take_prefix(<<H, Rest/binary>>, N) when N > 0 ->
|
||||||
|
Tail = take_prefix(Rest, N - 1),
|
||||||
|
<<H, Tail/binary>>.
|
||||||
|
|
||||||
|
%% populate_registry/0 — load the canonical genesis bundle and
|
||||||
|
%% register every entry in the running registry gen_server. The
|
||||||
|
%% caller is expected to have started the registry (via
|
||||||
|
%% registry:start_link/0) before calling this. Returns the count
|
||||||
|
%% of entries registered across all kinds.
|
||||||
|
populate_registry() ->
|
||||||
|
{ok, Sections} = read_genesis(),
|
||||||
|
populate_sections(Sections, 0).
|
||||||
|
|
||||||
|
populate_sections([], Count) -> Count;
|
||||||
|
populate_sections([{Kind, Entries} | Rest], Count) ->
|
||||||
|
populate_sections(Rest, Count + populate_entries(Kind, Entries, 0)).
|
||||||
|
|
||||||
|
populate_entries(_, [], Count) -> Count;
|
||||||
|
populate_entries(Kind, [{Name, Bytes} | Rest], Count) ->
|
||||||
|
BaseName = strip_sx_suffix(Name),
|
||||||
|
ok = registry:register(Kind, BaseName, Bytes),
|
||||||
|
populate_entries(Kind, Rest, Count + 1).
|
||||||
|
|
||||||
|
%% start/3 — one-call bring-up of the kernel substrate. Starts
|
||||||
|
%% the registry gen_server, populates it from the canonical
|
||||||
|
%% genesis bundle, then starts the nx_kernel gen_server with the
|
||||||
|
%% supplied actor identity / key / state. Returns the nx_kernel
|
||||||
|
%% Pid (gen_server start_link convention in this port returns the
|
||||||
|
%% raw Pid, not {ok, Pid}).
|
||||||
|
%%
|
||||||
|
%% Tests + production bring-up share this entry point. The
|
||||||
|
%% caller is still responsible for starting any application-level
|
||||||
|
%% projections and wiring them via nx_kernel:with_projections/1.
|
||||||
|
start(ActorId, KeySpec, ActorState) ->
|
||||||
|
registry:start_link(),
|
||||||
|
populate_registry(),
|
||||||
|
nx_kernel:start_link(ActorId, KeySpec, ActorState).
|
||||||
68
next/kernel/define_registry.erl
Normal file
68
next/kernel/define_registry.erl
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
-module(define_registry).
|
||||||
|
-export([fold/2, fold_fn/0, define_kind/1]).
|
||||||
|
|
||||||
|
%% Define-registry projection fold — Erlang-fun stand-in for the
|
||||||
|
%% genesis `define-registry.sx` body. The intent is identical: a
|
||||||
|
%% projection whose state is a registry-shaped property list, fed
|
||||||
|
%% by every `Create{Define*{...}}` activity. The SX body would
|
||||||
|
%% eventually replace this once an SX-source eval bridge lets the
|
||||||
|
%% kernel evaluate the genesis fold directly; until then this
|
||||||
|
%% Erlang module proves the meta-projection mechanism wires
|
||||||
|
%% through `projection:fold_fn` and `nx_kernel` cleanly.
|
||||||
|
%%
|
||||||
|
%% State shape mirrors `registry:new()` exactly:
|
||||||
|
%% [{Kind, [{Name, Entry}, ...]}, ...]
|
||||||
|
%% so callers can use `registry:lookup/3` etc. on the result.
|
||||||
|
%%
|
||||||
|
%% Type discrimination uses atoms (`define_activity`, …). Real SX
|
||||||
|
%% would carry the string forms ("DefineActivity", …); the bridge
|
||||||
|
%% will translate. See define_kind/1 for the mapping.
|
||||||
|
|
||||||
|
fold(Activity, State) ->
|
||||||
|
case envelope:get_field(type, Activity) of
|
||||||
|
{ok, create} -> fold_create(Activity, State);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_create(Activity, State) ->
|
||||||
|
case envelope:get_field(object, Activity) of
|
||||||
|
{ok, Obj} ->
|
||||||
|
case envelope:get_field(type, Obj) of
|
||||||
|
{ok, ObjType} ->
|
||||||
|
case define_kind(ObjType) of
|
||||||
|
not_a_define -> State;
|
||||||
|
Kind -> fold_register(Kind, Obj, State)
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_register(Kind, Obj, State) ->
|
||||||
|
case envelope:get_field(name, Obj) of
|
||||||
|
{ok, Name} ->
|
||||||
|
case registry:register(Kind, Name, Obj, State) of
|
||||||
|
{ok, NewState} -> NewState;
|
||||||
|
{error, unknown_kind} -> State
|
||||||
|
end;
|
||||||
|
not_found -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% fold_fn/0 — a 2-arity Erlang fun the projection module plants
|
||||||
|
%% in its record's :fold slot. Lets `projection:start_link/3`
|
||||||
|
%% wire define-registry directly.
|
||||||
|
fold_fn() ->
|
||||||
|
fun (Activity, State) -> fold(Activity, State) end.
|
||||||
|
|
||||||
|
%% define_kind/1 — discriminator from the inner Define* object's
|
||||||
|
%% :type atom to the registry kind atom. Anything unrecognised
|
||||||
|
%% returns not_a_define so the fold treats it as a pass-through.
|
||||||
|
|
||||||
|
define_kind(define_activity) -> activity_types;
|
||||||
|
define_kind(define_object) -> object_types;
|
||||||
|
define_kind(define_projection) -> projections;
|
||||||
|
define_kind(define_validator) -> validators;
|
||||||
|
define_kind(define_codec) -> codecs;
|
||||||
|
define_kind(define_sig_suite) -> sig_suites;
|
||||||
|
define_kind(define_audience) -> audience;
|
||||||
|
define_kind(_) -> not_a_define.
|
||||||
86
next/kernel/delivery.erl
Normal file
86
next/kernel/delivery.erl
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
-module(delivery).
|
||||||
|
-export([delivery_set/2, delivery_set/3,
|
||||||
|
collect_recipients/1, suppress_self/2, dedup/1,
|
||||||
|
expand_audience/3]).
|
||||||
|
|
||||||
|
%% Audience-resolving delivery set computation per design §13.4.
|
||||||
|
%%
|
||||||
|
%% delivery_set/2(Activity, KernelState) returns a sorted, deduped
|
||||||
|
%% list of ActorId atoms — every actor the outgoing Activity needs
|
||||||
|
%% to be POSTed to. Sources:
|
||||||
|
%% - Activity's `:to` field (single ActorId or list)
|
||||||
|
%% - Activity's `:cc` field (single ActorId or list)
|
||||||
|
%% - audience-symbol expansion of `public` and `followers`
|
||||||
|
%%
|
||||||
|
%% Self-delivery (the publishing actor reading their own activity
|
||||||
|
%% on a peer's behalf) is suppressed.
|
||||||
|
%%
|
||||||
|
%% Output for Step 7a is the bare ActorId list; Step 8 will resolve
|
||||||
|
%% each entry to `{PeerInstanceUrl, ActorId}` via the peer-actors
|
||||||
|
%% cache.
|
||||||
|
|
||||||
|
delivery_set(Activity, KernelState) ->
|
||||||
|
delivery_set(Activity, KernelState, follower_graph:new()).
|
||||||
|
|
||||||
|
delivery_set(Activity, KernelState, FollowerGraph) ->
|
||||||
|
Self = sender(Activity),
|
||||||
|
Raw = collect_recipients(Activity),
|
||||||
|
Expanded = expand_all(Raw, Self, KernelState, FollowerGraph),
|
||||||
|
Suppressed = suppress_self(Expanded, Self),
|
||||||
|
dedup(Suppressed).
|
||||||
|
|
||||||
|
%% collect_recipients/1 — flat list from :to + :cc, normalised so
|
||||||
|
%% each element is either an ActorId atom or an audience symbol
|
||||||
|
%% (`public` / `followers`).
|
||||||
|
|
||||||
|
collect_recipients(Activity) ->
|
||||||
|
To = envelope_field_list(to, Activity),
|
||||||
|
Cc = envelope_field_list(cc, Activity),
|
||||||
|
To ++ Cc.
|
||||||
|
|
||||||
|
envelope_field_list(Field, Activity) ->
|
||||||
|
case envelope:get_field(Field, Activity) of
|
||||||
|
not_found -> [];
|
||||||
|
{ok, V} when is_list(V) -> V;
|
||||||
|
{ok, V} -> [V]
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% expand_audience/3 — `followers` -> the sender's followers
|
||||||
|
%% proplist entry from a follower_graph state. `public` for v2
|
||||||
|
%% expands to the same list (per design §13.4: practical Public
|
||||||
|
%% fan-out is "every follower of the publishing actor"). The
|
||||||
|
%% explicit shared-inbox peer-instance model defers to v3.
|
||||||
|
%% Other symbols / explicit ActorIds pass through unchanged.
|
||||||
|
|
||||||
|
expand_audience(public, Sender, Graph) ->
|
||||||
|
follower_graph:followers(Sender, Graph);
|
||||||
|
expand_audience(followers, Sender, Graph) ->
|
||||||
|
follower_graph:followers(Sender, Graph);
|
||||||
|
expand_audience(X, _Sender, _Graph) -> [X].
|
||||||
|
|
||||||
|
expand_all([], _Self, _State, _Graph) -> [];
|
||||||
|
expand_all([X | Rest], Self, State, Graph) ->
|
||||||
|
expand_audience(X, Self, Graph) ++ expand_all(Rest, Self, State, Graph).
|
||||||
|
|
||||||
|
suppress_self([], _Self) -> [];
|
||||||
|
suppress_self([Self | Rest], Self) -> suppress_self(Rest, Self);
|
||||||
|
suppress_self([X | Rest], Self) -> [X | suppress_self(Rest, Self)].
|
||||||
|
|
||||||
|
dedup(L) -> dedup_acc(L, []).
|
||||||
|
|
||||||
|
dedup_acc([], Acc) -> Acc;
|
||||||
|
dedup_acc([X | Rest], Acc) ->
|
||||||
|
case contains(X, Acc) of
|
||||||
|
true -> dedup_acc(Rest, Acc);
|
||||||
|
false -> dedup_acc(Rest, Acc ++ [X])
|
||||||
|
end.
|
||||||
|
|
||||||
|
contains(_, []) -> false;
|
||||||
|
contains(X, [X | _]) -> true;
|
||||||
|
contains(X, [_ | Rest]) -> contains(X, Rest).
|
||||||
|
|
||||||
|
sender(Activity) ->
|
||||||
|
case envelope:get_field(actor, Activity) of
|
||||||
|
{ok, A} -> A;
|
||||||
|
_ -> nil
|
||||||
|
end.
|
||||||
209
next/kernel/delivery_state.erl
Normal file
209
next/kernel/delivery_state.erl
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
-module(delivery_state).
|
||||||
|
-export([new/0, fold/2, fold_fn/0,
|
||||||
|
peer_state/2, peers/1,
|
||||||
|
pending/2, attempts/2, next_retry/2, dead_letter/2]).
|
||||||
|
|
||||||
|
%% Delivery-state projection. Folds delivery events (enqueue /
|
||||||
|
%% delivered / failed / dead_lettered) into a per-peer worker-shaped
|
||||||
|
%% snapshot so the outbound queue survives kernel restart. Per design
|
||||||
|
%% §13.4 the worker state on restart is loaded from this projection
|
||||||
|
%% rather than reconstructed by re-driving the outbox log.
|
||||||
|
%%
|
||||||
|
%% Event proplist shape:
|
||||||
|
%% [{type, enqueued}, {peer, _}, {activity, _}]
|
||||||
|
%% [{type, delivered}, {peer, _}, {cid, _}]
|
||||||
|
%% [{type, failed}, {peer, _}, {cid, _}, {now, _}]
|
||||||
|
%% [{type, dead_lettered}, {peer, _}, {cid, _}]
|
||||||
|
%%
|
||||||
|
%% Projection state shape:
|
||||||
|
%% [{PeerId, WorkerProplist}, ...]
|
||||||
|
%%
|
||||||
|
%% WorkerProplist mirrors `delivery_worker:new/1`'s output so a fresh
|
||||||
|
%% gen_server can be hydrated with `delivery_worker:state_from_proj`
|
||||||
|
%% (lands when 8b-timer wires up). For Step 8c the projection only
|
||||||
|
%% tracks data — Step 8d-restart will wire the hydration helper.
|
||||||
|
|
||||||
|
new() -> [].
|
||||||
|
|
||||||
|
fold_fn() ->
|
||||||
|
fun (Event, State) -> fold(Event, State) end.
|
||||||
|
|
||||||
|
fold(Event, State) ->
|
||||||
|
case envelope:get_field(type, Event) of
|
||||||
|
{ok, enqueued} -> fold_enqueued(Event, State);
|
||||||
|
{ok, delivered} -> fold_delivered(Event, State);
|
||||||
|
{ok, failed} -> fold_failed(Event, State);
|
||||||
|
{ok, dead_lettered} -> fold_dead_lettered(Event, State);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_enqueued(Event, State) ->
|
||||||
|
case {envelope:get_field(peer, Event),
|
||||||
|
envelope:get_field(activity, Event)} of
|
||||||
|
{{ok, Peer}, {ok, Act}} ->
|
||||||
|
Worker = ensure_peer(Peer, State),
|
||||||
|
Pending = field(pending, Worker),
|
||||||
|
Worker1 = set_field(pending, Pending ++ [Act], Worker),
|
||||||
|
set_peer(Peer, Worker1, State);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_delivered(Event, State) ->
|
||||||
|
case {envelope:get_field(peer, Event),
|
||||||
|
envelope:get_field(cid, Event)} of
|
||||||
|
{{ok, Peer}, {ok, Cid}} ->
|
||||||
|
case find_keyed(Peer, State) of
|
||||||
|
{ok, Worker} ->
|
||||||
|
Worker1 = drop_pending_by_cid(Cid, Worker),
|
||||||
|
Worker2 = clear_retry_for(Cid, Worker1),
|
||||||
|
set_peer(Peer, Worker2, State);
|
||||||
|
_ -> State
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_failed(Event, State) ->
|
||||||
|
case {envelope:get_field(peer, Event),
|
||||||
|
envelope:get_field(cid, Event),
|
||||||
|
envelope:get_field(now, Event)} of
|
||||||
|
{{ok, Peer}, {ok, Cid}, {ok, Now}} ->
|
||||||
|
case find_keyed(Peer, State) of
|
||||||
|
{ok, Worker} ->
|
||||||
|
Attempts = field(attempts, Worker),
|
||||||
|
Current = case find_keyed(Cid, Attempts) of
|
||||||
|
{ok, N} -> N;
|
||||||
|
_ -> 0
|
||||||
|
end,
|
||||||
|
New = Current + 1,
|
||||||
|
Attempts1 = set_keyed(Cid, New, Attempts),
|
||||||
|
Worker1 = set_field(attempts, Attempts1, Worker),
|
||||||
|
Worker2 = case delivery_worker:backoff_for(New) of
|
||||||
|
dead_letter ->
|
||||||
|
dead_letter_pending(Cid, Worker1);
|
||||||
|
Seconds ->
|
||||||
|
NR = field(next_retry, Worker1),
|
||||||
|
NextAt = Now + Seconds,
|
||||||
|
set_field(next_retry, set_keyed(Cid, NextAt, NR), Worker1)
|
||||||
|
end,
|
||||||
|
set_peer(Peer, Worker2, State);
|
||||||
|
_ -> State
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_dead_lettered(Event, State) ->
|
||||||
|
case {envelope:get_field(peer, Event),
|
||||||
|
envelope:get_field(cid, Event)} of
|
||||||
|
{{ok, Peer}, {ok, Cid}} ->
|
||||||
|
case find_keyed(Peer, State) of
|
||||||
|
{ok, Worker} ->
|
||||||
|
set_peer(Peer, dead_letter_pending(Cid, Worker), State);
|
||||||
|
_ -> State
|
||||||
|
end;
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── Accessors ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
peer_state(Peer, State) ->
|
||||||
|
case find_keyed(Peer, State) of
|
||||||
|
{ok, Worker} -> {ok, Worker};
|
||||||
|
_ -> not_found
|
||||||
|
end.
|
||||||
|
|
||||||
|
peers(State) -> [P || {P, _} <- State].
|
||||||
|
|
||||||
|
pending(Peer, State) ->
|
||||||
|
worker_field(Peer, pending, State, []).
|
||||||
|
|
||||||
|
attempts(Peer, State) ->
|
||||||
|
worker_field(Peer, attempts, State, []).
|
||||||
|
|
||||||
|
next_retry(Peer, State) ->
|
||||||
|
worker_field(Peer, next_retry, State, []).
|
||||||
|
|
||||||
|
dead_letter(Peer, State) ->
|
||||||
|
worker_field(Peer, dead_letter, State, []).
|
||||||
|
|
||||||
|
%% ── Internal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
worker_field(Peer, Field, State, Default) ->
|
||||||
|
case find_keyed(Peer, State) of
|
||||||
|
{ok, Worker} ->
|
||||||
|
case find_keyed(Field, Worker) of
|
||||||
|
{ok, V} -> V;
|
||||||
|
_ -> Default
|
||||||
|
end;
|
||||||
|
_ -> Default
|
||||||
|
end.
|
||||||
|
|
||||||
|
ensure_peer(Peer, State) ->
|
||||||
|
case find_keyed(Peer, State) of
|
||||||
|
{ok, Worker} -> Worker;
|
||||||
|
_ -> empty_worker(Peer)
|
||||||
|
end.
|
||||||
|
|
||||||
|
empty_worker(Peer) ->
|
||||||
|
[{peer, Peer},
|
||||||
|
{pending, []},
|
||||||
|
{attempts, []},
|
||||||
|
{next_retry, []},
|
||||||
|
{dead_letter, []}].
|
||||||
|
|
||||||
|
set_peer(Peer, Worker, State) ->
|
||||||
|
set_keyed(Peer, Worker, State).
|
||||||
|
|
||||||
|
drop_pending_by_cid(Cid, Worker) ->
|
||||||
|
Pending = field(pending, Worker),
|
||||||
|
Kept = [A || A <- Pending, activity_cid(A) =/= Cid],
|
||||||
|
set_field(pending, Kept, Worker).
|
||||||
|
|
||||||
|
clear_retry_for(Cid, Worker) ->
|
||||||
|
A1 = del_keyed(Cid, field(attempts, Worker)),
|
||||||
|
NR1 = del_keyed(Cid, field(next_retry, Worker)),
|
||||||
|
set_field(attempts, A1, set_field(next_retry, NR1, Worker)).
|
||||||
|
|
||||||
|
dead_letter_pending(Cid, Worker) ->
|
||||||
|
Pending = field(pending, Worker),
|
||||||
|
{Match, Rest} = split_by_cid(Cid, Pending),
|
||||||
|
DL = field(dead_letter, Worker),
|
||||||
|
Worker1 = set_field(pending, Rest, Worker),
|
||||||
|
Worker2 = case Match of
|
||||||
|
none -> Worker1;
|
||||||
|
Act -> set_field(dead_letter, DL ++ [Act], Worker1)
|
||||||
|
end,
|
||||||
|
clear_retry_for(Cid, Worker2).
|
||||||
|
|
||||||
|
split_by_cid(Cid, List) -> split_by_cid(Cid, List, []).
|
||||||
|
split_by_cid(_, [], Acc) -> {none, lists:reverse(Acc)};
|
||||||
|
split_by_cid(Cid, [A | Rest], Acc) ->
|
||||||
|
case activity_cid(A) of
|
||||||
|
Cid -> {A, lists:reverse(Acc) ++ Rest};
|
||||||
|
_ -> split_by_cid(Cid, Rest, [A | Acc])
|
||||||
|
end.
|
||||||
|
|
||||||
|
activity_cid(Activity) ->
|
||||||
|
case envelope:get_field(id, Activity) of
|
||||||
|
{ok, Cid} -> Cid;
|
||||||
|
_ -> nil
|
||||||
|
end.
|
||||||
|
|
||||||
|
field(K, [{K, V} | _]) -> V;
|
||||||
|
field(K, [_ | Rest]) -> field(K, Rest);
|
||||||
|
field(_, []) -> undefined.
|
||||||
|
|
||||||
|
set_field(K, V, []) -> [{K, V}];
|
||||||
|
set_field(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set_field(K, V, [P | Rest]) -> [P | set_field(K, V, Rest)].
|
||||||
|
|
||||||
|
find_keyed(_, []) -> {error, not_found};
|
||||||
|
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||||
|
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||||
|
|
||||||
|
set_keyed(K, V, []) -> [{K, V}];
|
||||||
|
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||||
|
|
||||||
|
del_keyed(_, []) -> [];
|
||||||
|
del_keyed(K, [{K, _} | Rest]) -> Rest;
|
||||||
|
del_keyed(K, [P | Rest]) -> [P | del_keyed(K, Rest)].
|
||||||
426
next/kernel/delivery_worker.erl
Normal file
426
next/kernel/delivery_worker.erl
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
-module(delivery_worker).
|
||||||
|
-behaviour(gen_server).
|
||||||
|
-export([new/1, pending/1, peer/1,
|
||||||
|
enqueue_pure/3, drain_pure/1, deliver_one_pure/2,
|
||||||
|
backoff_for/1, schedule_for/1,
|
||||||
|
record_failure_pure/3, record_success_pure/2,
|
||||||
|
next_due_pure/2, attempts_for/2, next_retry_at/2,
|
||||||
|
dead_letter_list/1, timer_ref_for/2,
|
||||||
|
start_link/1, start_link/2, stop/1,
|
||||||
|
enqueue/2, flush/1, pending_srv/1, set_dispatch_fn/2,
|
||||||
|
state_srv/1]).
|
||||||
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||||
|
|
||||||
|
%% Outbound delivery worker per design §13.4. One gen_server per
|
||||||
|
%% peer instance (peer-id atom) holding a FIFO queue of pending
|
||||||
|
%% activities to deliver. v2 lands in stages:
|
||||||
|
%%
|
||||||
|
%% Step 8a pure-functional state shape, enqueue / drain /
|
||||||
|
%% schedule semantics + gen_server skeleton + tests
|
||||||
|
%% Step 8b retry / backoff schedule (30s / 5m / 30m / 6h / 24h)
|
||||||
|
%% + dead-letter list
|
||||||
|
%% Step 8c delivery-state projection so the queue survives
|
||||||
|
%% kernel restart
|
||||||
|
%% Step 8d outbox:publish/2 dispatches each delivery-set entry
|
||||||
|
%% to the matching worker
|
||||||
|
%% Step 8e httpc:request/4 BIF (substrate exception per briefing)
|
||||||
|
%% Step 8f real HTTP POST through the BIF + content-type wiring
|
||||||
|
%%
|
||||||
|
%% This file is 8a only — pure state + skeleton gen_server with the
|
||||||
|
%% APIs Step 8b-d will fill in. Real HTTP dispatch is stubbed via a
|
||||||
|
%% caller-supplied `:dispatch_fn` so tests can intercept and Step 8f
|
||||||
|
%% can plug in the live httpc call without touching the queue logic.
|
||||||
|
%%
|
||||||
|
%% State shape (pure):
|
||||||
|
%% [{peer, PeerId},
|
||||||
|
%% {pending, [Activity, ...]}, %% FIFO; head delivered first
|
||||||
|
%% {attempts, [{Cid, AttemptCount}, ...]},
|
||||||
|
%% {next_retry, [{Cid, NextRetryAt}, ...]}, %% Step 8b-pure
|
||||||
|
%% {dead_letter, [Activity, ...]},
|
||||||
|
%% {dispatch_fn, fun/1 | undefined}]
|
||||||
|
%%
|
||||||
|
%% gen_server registers under the peer-id atom (one worker per peer);
|
||||||
|
%% the same APIs work as pure-functional state transitions for tests.
|
||||||
|
|
||||||
|
%% ── Pure-functional API ─────────────────────────────────────────
|
||||||
|
|
||||||
|
new(PeerId) ->
|
||||||
|
[{peer, PeerId},
|
||||||
|
{pending, []},
|
||||||
|
{attempts, []},
|
||||||
|
{next_retry, []},
|
||||||
|
{dead_letter, []},
|
||||||
|
{timers, []},
|
||||||
|
{dispatch_fn, undefined}].
|
||||||
|
|
||||||
|
pending(State) -> field(pending, State).
|
||||||
|
peer(State) -> field(peer, State).
|
||||||
|
|
||||||
|
%% enqueue_pure/3 — append an activity to the queue. Returns new
|
||||||
|
%% state. Duplicate :id activities aren't deduplicated here — that's
|
||||||
|
%% the caller's job (Step 8d will pass each delivery-set entry once).
|
||||||
|
|
||||||
|
enqueue_pure(_PeerId, Activity, State) ->
|
||||||
|
Pending = field(pending, State),
|
||||||
|
set_field(pending, Pending ++ [Activity], State).
|
||||||
|
|
||||||
|
%% drain_pure/1 — attempt to deliver every queued activity through
|
||||||
|
%% the configured dispatch_fn. Returns {NewState, DeliveredCids,
|
||||||
|
%% RetryCids}. Activities that fail dispatch stay in :pending with
|
||||||
|
%% an incremented attempt counter — Step 8b will use the count to
|
||||||
|
%% pick a backoff slot.
|
||||||
|
|
||||||
|
drain_pure(State) ->
|
||||||
|
Pending = field(pending, State),
|
||||||
|
drain_loop(Pending, [], State, [], []).
|
||||||
|
|
||||||
|
drain_loop([], Kept, State, Delivered, Retry) ->
|
||||||
|
{set_field(pending, Kept, State), Delivered, Retry};
|
||||||
|
drain_loop([A | Rest], Kept, State, Delivered, Retry) ->
|
||||||
|
case deliver_one_pure(A, State) of
|
||||||
|
{ok, Cid} ->
|
||||||
|
drain_loop(Rest, Kept, State, Delivered ++ [Cid], Retry);
|
||||||
|
{error, Cid, _Reason} ->
|
||||||
|
State1 = bump_attempt(Cid, State),
|
||||||
|
drain_loop(Rest, Kept ++ [A], State1, Delivered, Retry ++ [Cid])
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% deliver_one_pure/2 — single-activity dispatch via the caller-
|
||||||
|
%% supplied dispatch_fn. Returns {ok, Cid} on success or {error,
|
||||||
|
%% Cid, Reason} on failure. With no dispatch_fn configured returns
|
||||||
|
%% {error, _, no_dispatch_fn} so callers know to wire one before
|
||||||
|
%% the worker is useful.
|
||||||
|
|
||||||
|
deliver_one_pure(Activity, State) ->
|
||||||
|
Cid = activity_cid(Activity),
|
||||||
|
case field(dispatch_fn, State) of
|
||||||
|
undefined -> {error, Cid, no_dispatch_fn};
|
||||||
|
Fn when is_function(Fn, 1) ->
|
||||||
|
case Fn(Activity) of
|
||||||
|
ok -> {ok, Cid};
|
||||||
|
{ok, _} -> {ok, Cid};
|
||||||
|
{error, Reason} -> {error, Cid, Reason};
|
||||||
|
Other -> {error, Cid, {bad_dispatch_return, Other}}
|
||||||
|
end;
|
||||||
|
_ -> {error, Cid, bad_dispatch_fn}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% backoff_for/1 — Step 8a returns the static schedule per the
|
||||||
|
%% plan; Step 8b wires it into the retry loop. Attempts are
|
||||||
|
%% 1-indexed (first retry uses slot 1).
|
||||||
|
%%
|
||||||
|
%% 30s / 5m / 30m / 6h / 24h then dead_letter.
|
||||||
|
|
||||||
|
backoff_for(0) -> 0;
|
||||||
|
backoff_for(1) -> 30;
|
||||||
|
backoff_for(2) -> 300; % 5 * 60
|
||||||
|
backoff_for(3) -> 1800; % 30 * 60
|
||||||
|
backoff_for(4) -> 21600; % 6 * 3600
|
||||||
|
backoff_for(5) -> 86400; % 24 * 3600
|
||||||
|
backoff_for(_) -> dead_letter.
|
||||||
|
|
||||||
|
schedule_for(Attempts) ->
|
||||||
|
case backoff_for(Attempts) of
|
||||||
|
dead_letter -> dead_letter;
|
||||||
|
Seconds -> {retry_in, Seconds}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── Step 8b-pure: retry-time bookkeeping ───────────────────────
|
||||||
|
%%
|
||||||
|
%% `record_failure_pure/3(Cid, Now, State)` — call after a failed
|
||||||
|
%% deliver_one. Bumps the per-cid attempt counter; if the new
|
||||||
|
%% attempt is past the dead-letter threshold, moves the matching
|
||||||
|
%% activity from :pending to :dead_letter. Otherwise records the
|
||||||
|
%% next retry time as Now + backoff_for(NewAttempt).
|
||||||
|
%%
|
||||||
|
%% Real timer wiring (erlang:send_after self-cast on the worker
|
||||||
|
%% pid) needs substrate support — Step 8b-timer when that lands.
|
||||||
|
%%
|
||||||
|
%% `record_success_pure/2(Cid, State)` — clears :attempts and
|
||||||
|
%% :next_retry entries for the cid; called after a successful
|
||||||
|
%% deliver_one.
|
||||||
|
%%
|
||||||
|
%% `next_due_pure/2(Now, State)` — returns the list of Cids whose
|
||||||
|
%% NextRetryAt has passed, in insertion order.
|
||||||
|
|
||||||
|
record_failure_pure(Cid, Now, State) ->
|
||||||
|
Attempts = field(attempts, State),
|
||||||
|
Current = case find_keyed(Cid, Attempts) of
|
||||||
|
{ok, N} -> N;
|
||||||
|
_ -> 0
|
||||||
|
end,
|
||||||
|
New = Current + 1,
|
||||||
|
State1 = set_field(attempts, set_keyed(Cid, New, Attempts), State),
|
||||||
|
case backoff_for(New) of
|
||||||
|
dead_letter ->
|
||||||
|
move_to_dead_letter(Cid, State1);
|
||||||
|
Seconds ->
|
||||||
|
NextAt = Now + Seconds,
|
||||||
|
NR = field(next_retry, State1),
|
||||||
|
set_field(next_retry, set_keyed(Cid, NextAt, NR), State1)
|
||||||
|
end.
|
||||||
|
|
||||||
|
record_success_pure(Cid, State) ->
|
||||||
|
A1 = del_keyed(Cid, field(attempts, State)),
|
||||||
|
NR1 = del_keyed(Cid, field(next_retry, State)),
|
||||||
|
set_field(attempts, A1, set_field(next_retry, NR1, State)).
|
||||||
|
|
||||||
|
%% next_due_pure/2 — Cids whose NextRetryAt <= Now. Preserves
|
||||||
|
%% insertion order so the worker drains them in FIFO retry order.
|
||||||
|
|
||||||
|
next_due_pure(Now, State) ->
|
||||||
|
[Cid || {Cid, At} <- field(next_retry, State), At =< Now].
|
||||||
|
|
||||||
|
attempts_for(Cid, State) ->
|
||||||
|
case find_keyed(Cid, field(attempts, State)) of
|
||||||
|
{ok, N} -> N;
|
||||||
|
_ -> 0
|
||||||
|
end.
|
||||||
|
|
||||||
|
next_retry_at(Cid, State) ->
|
||||||
|
case find_keyed(Cid, field(next_retry, State)) of
|
||||||
|
{ok, At} -> At;
|
||||||
|
_ -> undefined
|
||||||
|
end.
|
||||||
|
|
||||||
|
dead_letter_list(State) -> field(dead_letter, State).
|
||||||
|
|
||||||
|
%% Step 8b-timer: per-cid timer ref accessor. Exposed for tests so
|
||||||
|
%% they can assert a retry timer was scheduled (or wasn't, after a
|
||||||
|
%% success / dead-letter). Returns the live Ref or undefined.
|
||||||
|
|
||||||
|
timer_ref_for(Cid, State) ->
|
||||||
|
case find_keyed(Cid, field(timers, State)) of
|
||||||
|
{ok, Ref} -> Ref;
|
||||||
|
_ -> undefined
|
||||||
|
end.
|
||||||
|
|
||||||
|
move_to_dead_letter(Cid, State) ->
|
||||||
|
Pending = field(pending, State),
|
||||||
|
{Match, Rest} = take_by_cid(Cid, Pending, [], []),
|
||||||
|
DL = field(dead_letter, State),
|
||||||
|
State1 = set_field(pending, Rest, State),
|
||||||
|
State2 = case Match of
|
||||||
|
none -> State1;
|
||||||
|
Act -> set_field(dead_letter, DL ++ [Act], State1)
|
||||||
|
end,
|
||||||
|
NR = field(next_retry, State2),
|
||||||
|
set_field(next_retry, del_keyed(Cid, NR), State2).
|
||||||
|
|
||||||
|
take_by_cid(_, [], Acc, _) -> {none, lists:reverse(Acc)};
|
||||||
|
take_by_cid(Cid, [A | Rest], Acc, _) ->
|
||||||
|
case activity_cid(A) of
|
||||||
|
Cid -> {A, lists:reverse(Acc) ++ Rest};
|
||||||
|
_ -> take_by_cid(Cid, Rest, [A | Acc], 0)
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── gen_server wrapper ──────────────────────────────────────────
|
||||||
|
|
||||||
|
start_link(PeerId) ->
|
||||||
|
start_link(PeerId, undefined).
|
||||||
|
|
||||||
|
start_link(PeerId, DispatchFn) ->
|
||||||
|
Pid = gen_server:start_link(delivery_worker, [PeerId, DispatchFn]),
|
||||||
|
erlang:register(PeerId, Pid),
|
||||||
|
Pid.
|
||||||
|
|
||||||
|
stop(PeerId) ->
|
||||||
|
R = gen_server:call(PeerId, '$gen_stop'),
|
||||||
|
erlang:unregister(PeerId),
|
||||||
|
R.
|
||||||
|
|
||||||
|
enqueue(PeerId, Activity) ->
|
||||||
|
gen_server:call(PeerId, {enqueue, Activity}).
|
||||||
|
|
||||||
|
flush(PeerId) ->
|
||||||
|
gen_server:call(PeerId, flush).
|
||||||
|
|
||||||
|
pending_srv(PeerId) ->
|
||||||
|
gen_server:call(PeerId, get_pending).
|
||||||
|
|
||||||
|
set_dispatch_fn(PeerId, Fn) ->
|
||||||
|
gen_server:call(PeerId, {set_dispatch_fn, Fn}).
|
||||||
|
|
||||||
|
%% Step 8b-timer: return the worker's full state so tests can use the
|
||||||
|
%% pure introspection functions (attempts_for / next_retry_at /
|
||||||
|
%% timer_ref_for / dead_letter_list) against it.
|
||||||
|
|
||||||
|
state_srv(PeerId) ->
|
||||||
|
gen_server:call(PeerId, get_state).
|
||||||
|
|
||||||
|
%% gen_server callbacks
|
||||||
|
|
||||||
|
init([PeerId, DispatchFn]) ->
|
||||||
|
S0 = new(PeerId),
|
||||||
|
{ok, set_field(dispatch_fn, DispatchFn, S0)}.
|
||||||
|
|
||||||
|
handle_call({enqueue, Activity}, _From, State) ->
|
||||||
|
{reply, ok, enqueue_pure(field(peer, State), Activity, State)};
|
||||||
|
handle_call(flush, _From, State) ->
|
||||||
|
%% Step 8b-timer: drain (which already bumps :attempts via
|
||||||
|
%% bump_attempt on each failed deliver), then for each retried
|
||||||
|
%% Cid compute the backoff slot from the now-current attempt
|
||||||
|
%% count, set NextRetryAt, and arm a send_after self-cast.
|
||||||
|
%% handle_info({retry, Cid}, ...) fires when the slot elapses.
|
||||||
|
%% Reply shape unchanged.
|
||||||
|
{DrainState, Delivered, Retry} = drain_pure(State),
|
||||||
|
Now = monotonic_seconds(),
|
||||||
|
NewState = lists:foldl(
|
||||||
|
fun(Cid, S) -> arm_retry_timer(Cid, Now, S) end,
|
||||||
|
DrainState, Retry),
|
||||||
|
{reply, {ok, Delivered, Retry}, NewState};
|
||||||
|
handle_call(get_pending, _From, State) ->
|
||||||
|
{reply, field(pending, State), State};
|
||||||
|
handle_call(get_state, _From, State) ->
|
||||||
|
{reply, State, State};
|
||||||
|
handle_call({set_dispatch_fn, Fn}, _From, State) ->
|
||||||
|
{reply, ok, set_field(dispatch_fn, Fn, State)}.
|
||||||
|
|
||||||
|
handle_cast(_, S) -> {noreply, S}.
|
||||||
|
|
||||||
|
%% Step 8b-timer: a retry timer fired. Pull the activity by Cid from
|
||||||
|
%% the pending queue (it might have been drained meanwhile by a
|
||||||
|
%% concurrent flush — if so, we just clear bookkeeping and exit).
|
||||||
|
%% Run deliver_one_pure: success clears retry state; failure bumps
|
||||||
|
%% the counter and schedules the next slot — or dead-letters if the
|
||||||
|
%% sixth attempt failed.
|
||||||
|
|
||||||
|
handle_info({retry, Cid}, State) ->
|
||||||
|
%% Clear the timer ref we just consumed.
|
||||||
|
State0 = clear_timer_ref(Cid, State),
|
||||||
|
case take_by_cid(Cid, field(pending, State0), [], 0) of
|
||||||
|
{none, _} ->
|
||||||
|
%% Already drained / dead-lettered. Clear any stale
|
||||||
|
%% bookkeeping in case the cid is half-tracked.
|
||||||
|
{noreply, record_success_pure(Cid, State0)};
|
||||||
|
{Activity, Rest} ->
|
||||||
|
case deliver_one_pure(Activity, State0) of
|
||||||
|
{ok, _} ->
|
||||||
|
State1 = set_field(pending, Rest, State0),
|
||||||
|
State2 = record_success_pure(Cid, State1),
|
||||||
|
{noreply, State2};
|
||||||
|
{error, _, _} ->
|
||||||
|
%% Keep the activity in pending; record_failure
|
||||||
|
%% leaves :pending alone (or dead-letters it on
|
||||||
|
%% slot 6).
|
||||||
|
Now = monotonic_seconds(),
|
||||||
|
State1 = schedule_retry_for(Cid, Now, State0),
|
||||||
|
{noreply, State1}
|
||||||
|
end
|
||||||
|
end;
|
||||||
|
handle_info(_, S) -> {noreply, S}.
|
||||||
|
|
||||||
|
%% Step 8b-timer helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
%% arm_retry_timer/3 — POST-DRAIN form. Used from handle_call(flush)
|
||||||
|
%% after drain_pure has already bumped :attempts via bump_attempt.
|
||||||
|
%% Sets next_retry_at = Now + backoff(attempts) and schedules the
|
||||||
|
%% send_after self-cast. On the dead-letter slot (attempt 6), moves
|
||||||
|
%% the activity from :pending to :dead_letter and arms no timer.
|
||||||
|
|
||||||
|
arm_retry_timer(Cid, Now, State) ->
|
||||||
|
State0 = cancel_timer_for(Cid, State),
|
||||||
|
Attempts = attempts_for(Cid, State0),
|
||||||
|
case backoff_for(Attempts) of
|
||||||
|
dead_letter ->
|
||||||
|
move_to_dead_letter(Cid, State0);
|
||||||
|
Seconds ->
|
||||||
|
NextAt = Now + Seconds,
|
||||||
|
NR = field(next_retry, State0),
|
||||||
|
State1 = set_field(next_retry, set_keyed(Cid, NextAt, NR), State0),
|
||||||
|
Ms = Seconds * 1000,
|
||||||
|
Ref = erlang:send_after(Ms, self(), {retry, Cid}),
|
||||||
|
Timers = field(timers, State1),
|
||||||
|
set_field(timers, set_keyed(Cid, Ref, Timers), State1)
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% schedule_retry_for/3 — POST-RETRY-ATTEMPT form. Used from
|
||||||
|
%% handle_info({retry, Cid}, ...) when the retry attempt failed.
|
||||||
|
%% Bookkeep one failure and arm the next retry timer (or promote
|
||||||
|
%% to dead-letter, in which case no timer is needed).
|
||||||
|
|
||||||
|
schedule_retry_for(Cid, Now, State) ->
|
||||||
|
%% Cancel any in-flight timer for this Cid before scheduling a new
|
||||||
|
%% one. Without the cancel a stale timer can still fire after
|
||||||
|
%% record_success has cleared the cid, the handle_info no-match
|
||||||
|
%% branch silently absorbs it — but it keeps the scheduler's
|
||||||
|
%% run-loop alive long after the work is done. A pure clear (no
|
||||||
|
%% cancel) is fine when the timer's own firing brought us here,
|
||||||
|
%% so the explicit cancel only matters for the flush path.
|
||||||
|
State0 = cancel_timer_for(Cid, State),
|
||||||
|
State1 = record_failure_pure(Cid, Now, State0),
|
||||||
|
Attempts = attempts_for(Cid, State1),
|
||||||
|
case backoff_for(Attempts) of
|
||||||
|
dead_letter ->
|
||||||
|
State1;
|
||||||
|
Seconds ->
|
||||||
|
Ms = Seconds * 1000,
|
||||||
|
Ref = erlang:send_after(Ms, self(), {retry, Cid}),
|
||||||
|
Timers = field(timers, State1),
|
||||||
|
set_field(timers, set_keyed(Cid, Ref, Timers), State1)
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Cancel the live timer for Cid (if any) and clear it from :timers.
|
||||||
|
%% Idempotent — silent no-op if there isn't one.
|
||||||
|
|
||||||
|
cancel_timer_for(Cid, State) ->
|
||||||
|
Timers = field(timers, State),
|
||||||
|
case find_keyed(Cid, Timers) of
|
||||||
|
{ok, Ref} ->
|
||||||
|
erlang:cancel_timer(Ref),
|
||||||
|
set_field(timers, del_keyed(Cid, Timers), State);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Drop the :timers entry for Cid without calling cancel_timer — used
|
||||||
|
%% when the timer's own firing brought us into handle_info and the ref
|
||||||
|
%% is already consumed.
|
||||||
|
|
||||||
|
clear_timer_ref(Cid, State) ->
|
||||||
|
Timers = field(timers, State),
|
||||||
|
case find_keyed(Cid, Timers) of
|
||||||
|
{ok, _Ref} -> set_field(timers, del_keyed(Cid, Timers), State);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Step 8b-timer: bookkeeping uses seconds (matches backoff_for /
|
||||||
|
%% record_failure_pure / next_retry_at). The monotonic clock reports
|
||||||
|
%% ms; we floor to seconds here to keep all the comparisons aligned.
|
||||||
|
|
||||||
|
monotonic_seconds() -> erlang:monotonic_time() div 1000.
|
||||||
|
|
||||||
|
%% ── Internal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
activity_cid(Activity) ->
|
||||||
|
case envelope:get_field(id, Activity) of
|
||||||
|
{ok, Cid} -> Cid;
|
||||||
|
_ -> nil
|
||||||
|
end.
|
||||||
|
|
||||||
|
bump_attempt(Cid, State) ->
|
||||||
|
Attempts = field(attempts, State),
|
||||||
|
Current = case find_keyed(Cid, Attempts) of
|
||||||
|
{ok, N} -> N;
|
||||||
|
_ -> 0
|
||||||
|
end,
|
||||||
|
set_field(attempts, set_keyed(Cid, Current + 1, Attempts), State).
|
||||||
|
|
||||||
|
field(K, [{K, V} | _]) -> V;
|
||||||
|
field(K, [_ | Rest]) -> field(K, Rest);
|
||||||
|
field(_, []) -> undefined.
|
||||||
|
|
||||||
|
set_field(K, V, []) -> [{K, V}];
|
||||||
|
set_field(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set_field(K, V, [P | Rest]) -> [P | set_field(K, V, Rest)].
|
||||||
|
|
||||||
|
find_keyed(_, []) -> {error, not_found};
|
||||||
|
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||||
|
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||||
|
|
||||||
|
set_keyed(K, V, []) -> [{K, V}];
|
||||||
|
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||||
|
|
||||||
|
del_keyed(_, []) -> [];
|
||||||
|
del_keyed(K, [{K, _} | Rest]) -> Rest;
|
||||||
|
del_keyed(K, [P | Rest]) -> [P | del_keyed(K, Rest)].
|
||||||
98
next/kernel/discovery.erl
Normal file
98
next/kernel/discovery.erl
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
-module(discovery).
|
||||||
|
-export([parse_acct/1, parse_resource/1,
|
||||||
|
actor_url_for/2, webfinger_body/3]).
|
||||||
|
|
||||||
|
%% Discovery primitives per design §13.7. Step 10a covers the
|
||||||
|
%% local-side webfinger endpoint (responding when a peer asks
|
||||||
|
%% "where does acct:alice@here live?"); the peer-fetch direction
|
||||||
|
%% (loading a peer's actor doc lazily on first inbound) is Step 10b
|
||||||
|
%% and gates on Blockers #2 (native http-request primitive).
|
||||||
|
%%
|
||||||
|
%% parse_acct/1 — accept a binary in either form:
|
||||||
|
%% <<"acct:alice@host:port">> (full prefixed URI)
|
||||||
|
%% <<"alice@host:port">> (bare account, prefix optional)
|
||||||
|
%% Returns {ok, User, Host} | {error, Reason}.
|
||||||
|
%%
|
||||||
|
%% parse_resource/1 — the resource= query parameter from
|
||||||
|
%% /.well-known/webfinger. Same shape as parse_acct.
|
||||||
|
%%
|
||||||
|
%% actor_url_for/2(User, Host) — synthesises the canonical
|
||||||
|
%% per-actor URL `<scheme>://<host>/actors/<user>`. v2 hardcodes
|
||||||
|
%% http://; TLS / https is v3 (Blockers gate).
|
||||||
|
%%
|
||||||
|
%% webfinger_body/3 — builds the JSON response body.
|
||||||
|
|
||||||
|
%% ── parse_acct / parse_resource ─────────────────────────────────
|
||||||
|
|
||||||
|
%% "acct:" -> 5 bytes: 97 99 99 116 58
|
||||||
|
parse_acct(Bin) when is_binary(Bin) ->
|
||||||
|
AcctPrefix = <<97,99,99,116,58>>,
|
||||||
|
case strip_prefix(AcctPrefix, Bin) of
|
||||||
|
{ok, Rest} -> split_user_host(Rest);
|
||||||
|
nomatch -> split_user_host(Bin)
|
||||||
|
end;
|
||||||
|
parse_acct(_) -> {error, bad_input}.
|
||||||
|
|
||||||
|
parse_resource(Bin) -> parse_acct(Bin).
|
||||||
|
|
||||||
|
%% strip_prefix/2 — return {ok, Rest} when Bin starts with Prefix,
|
||||||
|
%% else nomatch. Substrate has no proper prefix-match BIF; this
|
||||||
|
%% byte-walks.
|
||||||
|
|
||||||
|
strip_prefix(<<>>, Rest) -> {ok, Rest};
|
||||||
|
strip_prefix(<<B, PRest/binary>>, <<B, RRest/binary>>) ->
|
||||||
|
strip_prefix(PRest, RRest);
|
||||||
|
strip_prefix(_, _) -> nomatch.
|
||||||
|
|
||||||
|
%% split_user_host/1 — split a `user@host[:port]` binary at the
|
||||||
|
%% first `@`. Returns {ok, User, Host} where Host may include the
|
||||||
|
%% optional port suffix.
|
||||||
|
|
||||||
|
split_user_host(Bin) ->
|
||||||
|
case split_at(64, Bin) of % 64 = '@'
|
||||||
|
{Before, After} when byte_size(Before) > 0, byte_size(After) > 0 ->
|
||||||
|
{ok, Before, After};
|
||||||
|
_ ->
|
||||||
|
{error, bad_acct}
|
||||||
|
end.
|
||||||
|
|
||||||
|
split_at(Byte, Bin) ->
|
||||||
|
split_at(Byte, Bin, <<>>).
|
||||||
|
|
||||||
|
split_at(_, <<>>, Acc) ->
|
||||||
|
{Acc, <<>>};
|
||||||
|
split_at(Byte, <<Byte, Rest/binary>>, Acc) ->
|
||||||
|
{Acc, Rest};
|
||||||
|
split_at(Byte, <<B, Rest/binary>>, Acc) ->
|
||||||
|
split_at(Byte, Rest, <<Acc/binary, B>>).
|
||||||
|
|
||||||
|
%% ── URL synthesis ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
%% "http://" -> 7 bytes | "/actors/" -> 8 bytes
|
||||||
|
actor_url_for(User, Host) ->
|
||||||
|
Pre = <<104,116,116,112,58,47,47>>, % "http://"
|
||||||
|
Mid = <<47,97,99,116,111,114,115,47>>, % "/actors/"
|
||||||
|
<<Pre/binary, Host/binary, Mid/binary, User/binary>>.
|
||||||
|
|
||||||
|
%% ── webfinger JSON body ────────────────────────────────────────
|
||||||
|
%%
|
||||||
|
%% Mastodon-shape per RFC 7033:
|
||||||
|
%% {"subject":"acct:<user>@<host>",
|
||||||
|
%% "links":[{"rel":"self",
|
||||||
|
%% "type":"application/activity+json",
|
||||||
|
%% "href":"<actor_url>"}]}
|
||||||
|
%%
|
||||||
|
%% Hand-rolled byte concatenation — no JSON BIF on this port. The
|
||||||
|
%% caller has already validated User + Host; we don't need to
|
||||||
|
%% re-escape (Mastodon's webfinger inputs are alphanumeric +
|
||||||
|
%% .-_ in practice).
|
||||||
|
|
||||||
|
webfinger_body(User, Host, ActorUrl) ->
|
||||||
|
AcctPre = <<123,34,115,117,98,106,101,99,116,34,58,34,97,99,99,116,58>>, % '{"subject":"acct:'
|
||||||
|
AcctAt = <<64>>, % '@'
|
||||||
|
LinksHd = <<34,44,34,108,105,110,107,115,34,58,91,123,34,114,101,108,34,58,34,115,101,108,102,34,44,
|
||||||
|
34,116,121,112,101,34,58,34,97,112,112,108,105,99,97,116,105,111,110,47,97,99,116,
|
||||||
|
105,118,105,116,121,43,106,115,111,110,34,44,34,104,114,101,102,34,58,34>>, % '","links":[{"rel":"self","type":"application/activity+json","href":"'
|
||||||
|
LinksTl = <<34,125,93,125,10>>, % '"}]}\n'
|
||||||
|
<<AcctPre/binary, User/binary, AcctAt/binary, Host/binary,
|
||||||
|
LinksHd/binary, ActorUrl/binary, LinksTl/binary>>.
|
||||||
89
next/kernel/discovery_fetch.erl
Normal file
89
next/kernel/discovery_fetch.erl
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
-module(discovery_fetch).
|
||||||
|
-export([make_fetch_fn/1,
|
||||||
|
fetch/2,
|
||||||
|
actor_doc_url/2,
|
||||||
|
decode_body/1,
|
||||||
|
accept_header/0]).
|
||||||
|
|
||||||
|
%% Live peer-actor-doc fetch for peer_actors — Step 10c per design
|
||||||
|
%% §13.6. The peer_actors gen_server already exposes
|
||||||
|
%% lookup_or_fetch_srv/2(PeerId, FetchFn) where FetchFn is a
|
||||||
|
%% 1-arity closure that returns {ok, PeerAS} | {error, Reason} on
|
||||||
|
%% cache miss. For tests we wire a fake FetchFn that returns a
|
||||||
|
%% pre-baked AS; for live federation we wire the closure this
|
||||||
|
%% module produces — it GETs <base>/actors/<peer> with an Accept
|
||||||
|
%% header that asks for the actor_doc format
|
||||||
|
%% (http_server.erl Step 10c), decodes the response body via
|
||||||
|
%% term_codec, and returns the AS proplist.
|
||||||
|
%%
|
||||||
|
%% Cfg shape (reuses dispatch_http's peer URL resolution so a
|
||||||
|
%% single Cfg threads through both delivery and discovery):
|
||||||
|
%% {peer_url, [{PeerId, BaseUrl}, ...]}
|
||||||
|
%% {peer_url_fn, fun ((PeerId) -> {ok, BaseUrl} | not_found)}
|
||||||
|
%%
|
||||||
|
%% BaseUrl shape: <<"http://host:port">> (no trailing slash; this
|
||||||
|
%% module appends the path). PeerId is the actor atom.
|
||||||
|
%%
|
||||||
|
%% Outcomes:
|
||||||
|
%% 2xx + decodable body -> {ok, PeerAS}
|
||||||
|
%% 2xx + bad body -> {error, bad_actor_doc}
|
||||||
|
%% non-2xx -> {error, {status, N}}
|
||||||
|
%% resolver miss -> {error, no_peer_url}
|
||||||
|
%% transport -> {error, Reason}
|
||||||
|
%%
|
||||||
|
%% Cache write semantics live in peer_actors:lookup_or_fetch/3 —
|
||||||
|
%% successful fetches store; errors do NOT poison so callers can
|
||||||
|
%% retry on transients.
|
||||||
|
|
||||||
|
%% ── Accept header ────────────────────────────────────────────
|
||||||
|
%% "application/vnd.fed-sx.actor-doc" — same MIME the http_server
|
||||||
|
%% content_type_for(actor_doc) emits, so the Accept negotiation
|
||||||
|
%% in accept_format/1 routes the peer's response to the term_codec
|
||||||
|
%% serializer arm.
|
||||||
|
accept_header() ->
|
||||||
|
<<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>>.
|
||||||
|
|
||||||
|
%% ── public API ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
make_fetch_fn(Cfg) ->
|
||||||
|
fun (PeerId) ->
|
||||||
|
case dispatch_http:resolve_peer_url(PeerId, Cfg) of
|
||||||
|
{error, R} -> {error, R};
|
||||||
|
{ok, BaseUrl} -> fetch(actor_doc_url(BaseUrl, PeerId), Cfg)
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
fetch(Url, _Cfg) ->
|
||||||
|
AcceptKey = <<97,99,99,101,112,116>>, % "accept"
|
||||||
|
Headers = [{AcceptKey, accept_header()}],
|
||||||
|
try httpc:request(Url, get, Headers, <<>>) of
|
||||||
|
{ok, Status, _H, Body} when Status >= 200, Status < 300 ->
|
||||||
|
decode_body(Body);
|
||||||
|
{ok, Status, _H, _B} ->
|
||||||
|
{error, {status, Status}};
|
||||||
|
Other ->
|
||||||
|
{error, {bad_response, Other}}
|
||||||
|
catch
|
||||||
|
error:Reason -> {error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% actor_doc_url/2 — <BaseUrl>/actors/<peer>. PeerId is the actor
|
||||||
|
%% atom; rendered to a binary via its name (matches the same path
|
||||||
|
%% layout http_server.erl uses for the route registration at
|
||||||
|
%% prefix "/actors/").
|
||||||
|
actor_doc_url(BaseUrl, PeerId) when is_atom(PeerId) ->
|
||||||
|
PeerBin = list_to_binary(atom_to_list(PeerId)),
|
||||||
|
%% "/actors/" — 8 bytes
|
||||||
|
Prefix = <<47,97,99,116,111,114,115,47>>,
|
||||||
|
<<BaseUrl/binary, Prefix/binary, PeerBin/binary>>.
|
||||||
|
|
||||||
|
%% decode_body/1 — round the wire body back through term_codec.
|
||||||
|
%% Returns {ok, AS} on a proplist-shaped decode (matching the
|
||||||
|
%% peer-actor-state schema), {error, bad_actor_doc} otherwise.
|
||||||
|
decode_body(Body) ->
|
||||||
|
case term_codec:decode(Body) of
|
||||||
|
{ok, AS, _} when is_list(AS) -> {ok, AS};
|
||||||
|
_ -> {error, bad_actor_doc}
|
||||||
|
end.
|
||||||
119
next/kernel/dispatch_http.erl
Normal file
119
next/kernel/dispatch_http.erl
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
-module(dispatch_http).
|
||||||
|
-export([make_dispatch_fn/2,
|
||||||
|
dispatch/3,
|
||||||
|
inbox_url/2,
|
||||||
|
resolve_peer_url/2,
|
||||||
|
content_type/0]).
|
||||||
|
|
||||||
|
%% Live HTTP dispatch for delivery_worker — Step 8f per design §13.4.
|
||||||
|
%%
|
||||||
|
%% delivery_worker takes an opaque `dispatch_fn :: fun(Activity) ->
|
||||||
|
%% ok | {ok, _} | {error, Reason}`. For tests we wire a fake one
|
||||||
|
%% that records calls; for live federation we wire the closure this
|
||||||
|
%% module produces — a 1-arity fun that encodes the activity with
|
||||||
|
%% term_codec, looks up the peer's URL base, and POSTs to
|
||||||
|
%% `<base>/actors/<peer>/inbox` via httpc:request/4 (the BIF
|
||||||
|
%% wrapper Step 8e landed in lib/erlang/runtime.sx around the
|
||||||
|
%% native http-request primitive from fed-prims).
|
||||||
|
%%
|
||||||
|
%% Cfg shape (composable, priority order):
|
||||||
|
%% {peer_url, [{PeerId, BaseUrl::binary}, ...]}
|
||||||
|
%% Static map; tests + small static deployments. PeerId is
|
||||||
|
%% the actor atom (alice / bob / ...).
|
||||||
|
%% {peer_url_fn, fun((PeerId) -> {ok, BaseUrl} | not_found)}
|
||||||
|
%% Dynamic lookup; used when peer_actors gen_server caches a
|
||||||
|
%% discovery result (Step 10c will plumb this).
|
||||||
|
%%
|
||||||
|
%% BaseUrl is the scheme+host+port of the peer's HTTP server, e.g.
|
||||||
|
%% <<"http://127.0.0.1:8123">>. The inbox URL is built by
|
||||||
|
%% appending /actors/<peer>/inbox so callers don't have to know the
|
||||||
|
%% wire path layout.
|
||||||
|
%%
|
||||||
|
%% Dispatch outcome:
|
||||||
|
%% 2xx -> ok (delivery_worker drops the entry)
|
||||||
|
%% non-2xx -> {error, {status, N}}
|
||||||
|
%% resolver miss -> {error, no_peer_url}
|
||||||
|
%% transport -> {error, Reason} (BIF-raised, caught here)
|
||||||
|
|
||||||
|
%% ── content-type ─────────────────────────────────────────────
|
||||||
|
%% "application/vnd.fed-sx.activity" — picked to be distinct from
|
||||||
|
%% the existing http_server content types (text/json/sx/cbor) since
|
||||||
|
%% the wire bytes are term_codec's custom netstring-ish format, not
|
||||||
|
%% any of them. The receiver's handle_inbox_post/3 in
|
||||||
|
%% http_server.erl doesn't gate on content-type yet; it just hands
|
||||||
|
%% the body to term_codec:decode. We still send a real MIME so
|
||||||
|
%% intermediaries (proxies, load balancers, logs) see something
|
||||||
|
%% honest. Substrate Note: M2 doesn't add a content_type_for/1
|
||||||
|
%% clause to http_server because that's serving outbound responses
|
||||||
|
%% (the dispatch direction is FROM us; the receiver shapes its
|
||||||
|
%% own response).
|
||||||
|
content_type() ->
|
||||||
|
%% "application/vnd.fed-sx.activity"
|
||||||
|
<<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,105,118,105,116,121>>.
|
||||||
|
|
||||||
|
%% ── public API ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
make_dispatch_fn(PeerId, Cfg) ->
|
||||||
|
fun (Activity) ->
|
||||||
|
case resolve_peer_url(PeerId, Cfg) of
|
||||||
|
{error, R} ->
|
||||||
|
{error, R};
|
||||||
|
{ok, BaseUrl} ->
|
||||||
|
Url = inbox_url(BaseUrl, PeerId),
|
||||||
|
dispatch(Url, Activity, Cfg)
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
dispatch(Url, Activity, _Cfg) ->
|
||||||
|
Body = term_codec:encode(Activity),
|
||||||
|
Headers = [{<<99,111,110,116,101,110,116,45,116,121,112,101>>,
|
||||||
|
content_type()}],
|
||||||
|
%% This port's try/catch needs a literal class atom (not Class:R).
|
||||||
|
%% The BIF wrapper raises error:{network, _} on transport failure
|
||||||
|
%% and error:badarg on shape failure; both reach us as `error`.
|
||||||
|
try httpc:request(Url, post, Headers, Body) of
|
||||||
|
{ok, Status, _H, _B} when Status >= 200, Status < 300 -> ok;
|
||||||
|
{ok, Status, _H, _B} -> {error, {status, Status}};
|
||||||
|
Other -> {error, {bad_response, Other}}
|
||||||
|
catch
|
||||||
|
error:Reason -> {error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% inbox_url/2 — concatenate BaseUrl + "/actors/" + PeerId + "/inbox".
|
||||||
|
%% PeerId is the actor atom; rendered to a binary via its name.
|
||||||
|
inbox_url(BaseUrl, PeerId) when is_atom(PeerId) ->
|
||||||
|
PeerBin = list_to_binary(atom_to_list(PeerId)),
|
||||||
|
%% "/actors/" — 47,97,99,116,111,114,115,47
|
||||||
|
Prefix = <<47,97,99,116,111,114,115,47>>,
|
||||||
|
%% "/inbox" — 47,105,110,98,111,120
|
||||||
|
Suffix = <<47,105,110,98,111,120>>,
|
||||||
|
<<BaseUrl/binary, Prefix/binary, PeerBin/binary, Suffix/binary>>.
|
||||||
|
|
||||||
|
%% resolve_peer_url/2 — static :peer_url map first (tests), then
|
||||||
|
%% :peer_url_fn closure (Step 10c will hand one in once peer_actors
|
||||||
|
%% caches discovered URLs).
|
||||||
|
resolve_peer_url(PeerId, Cfg) ->
|
||||||
|
case envelope:get_field(peer_url, Cfg) of
|
||||||
|
{ok, Map} when is_list(Map) ->
|
||||||
|
case lookup_peer(PeerId, Map) of
|
||||||
|
{ok, U} -> {ok, U};
|
||||||
|
_ -> try_fn(PeerId, Cfg)
|
||||||
|
end;
|
||||||
|
_ -> try_fn(PeerId, Cfg)
|
||||||
|
end.
|
||||||
|
|
||||||
|
try_fn(PeerId, Cfg) ->
|
||||||
|
case envelope:get_field(peer_url_fn, Cfg) of
|
||||||
|
{ok, Fn} when is_function(Fn, 1) ->
|
||||||
|
case Fn(PeerId) of
|
||||||
|
{ok, U} when is_binary(U) -> {ok, U};
|
||||||
|
_ -> {error, no_peer_url}
|
||||||
|
end;
|
||||||
|
_ -> {error, no_peer_url}
|
||||||
|
end.
|
||||||
|
|
||||||
|
lookup_peer(_PeerId, []) -> not_found;
|
||||||
|
lookup_peer(PeerId, [{PeerId, Url} | _]) -> {ok, Url};
|
||||||
|
lookup_peer(PeerId, [_ | Rest]) -> lookup_peer(PeerId, Rest).
|
||||||
118
next/kernel/endorsement_state.erl
Normal file
118
next/kernel/endorsement_state.erl
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
-module(endorsement_state).
|
||||||
|
-export([new/0, fold/2, fold_fn/0,
|
||||||
|
counters_for/2, total_for/2, kinds_for/2,
|
||||||
|
endorsers_for/3, has_endorsed/4]).
|
||||||
|
|
||||||
|
%% Endorsement counter projection. Folds Endorse activities into a
|
||||||
|
%% per-target-Cid + per-kind counter so projections can serve
|
||||||
|
%% "how many likes does this Note have" / "list everyone who shared
|
||||||
|
%% this Announce" queries.
|
||||||
|
%%
|
||||||
|
%% Endorse envelope shape (per next/genesis/activity-types/endorse.sx):
|
||||||
|
%% [{type, endorse},
|
||||||
|
%% {actor, ActorId},
|
||||||
|
%% {object, TargetCidBinary},
|
||||||
|
%% {kind, KindAtomOrBinary},
|
||||||
|
%% ...]
|
||||||
|
%%
|
||||||
|
%% State shape:
|
||||||
|
%% [{TargetCid, [{Kind, [{ActorId, Count}, ...]}, ...]}, ...]
|
||||||
|
%%
|
||||||
|
%% Each ActorId can endorse the same target multiple times under
|
||||||
|
%% the same kind (e.g. like → unlike → like → ...); the counter
|
||||||
|
%% tracks how many *net* endorsement events fired. Step 11b ships
|
||||||
|
%% the additive counter only; the unlike / un-endorse semantics
|
||||||
|
%% (Undo{Endorse}) and reaction-toggling defer to a follow-up.
|
||||||
|
|
||||||
|
new() -> [].
|
||||||
|
|
||||||
|
fold_fn() ->
|
||||||
|
fun (Activity, State) -> fold(Activity, State) end.
|
||||||
|
|
||||||
|
fold(Activity, State) ->
|
||||||
|
case envelope:get_field(type, Activity) of
|
||||||
|
{ok, endorse} -> fold_endorse(Activity, State);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_endorse(Activity, State) ->
|
||||||
|
case {envelope:get_field(actor, Activity),
|
||||||
|
envelope:get_field(object, Activity),
|
||||||
|
envelope:get_field(kind, Activity)} of
|
||||||
|
{{ok, Actor}, {ok, Cid}, {ok, Kind}} ->
|
||||||
|
bump(Cid, Kind, Actor, State);
|
||||||
|
_ ->
|
||||||
|
State
|
||||||
|
end.
|
||||||
|
|
||||||
|
bump(Cid, Kind, Actor, State) ->
|
||||||
|
KindMap = case find_keyed(Cid, State) of
|
||||||
|
{ok, KM} -> KM;
|
||||||
|
_ -> []
|
||||||
|
end,
|
||||||
|
ActorMap = case find_keyed(Kind, KindMap) of
|
||||||
|
{ok, AM} -> AM;
|
||||||
|
_ -> []
|
||||||
|
end,
|
||||||
|
Current = case find_keyed(Actor, ActorMap) of
|
||||||
|
{ok, N} -> N;
|
||||||
|
_ -> 0
|
||||||
|
end,
|
||||||
|
ActorMap1 = set_keyed(Actor, Current + 1, ActorMap),
|
||||||
|
KindMap1 = set_keyed(Kind, ActorMap1, KindMap),
|
||||||
|
set_keyed(Cid, KindMap1, State).
|
||||||
|
|
||||||
|
%% ── Read-side accessors ───────────────────────────────────────
|
||||||
|
|
||||||
|
%% counters_for(Cid, State) -> [{Kind, TotalCount}, ...]
|
||||||
|
%% Sum per-kind across all endorsers.
|
||||||
|
|
||||||
|
counters_for(Cid, State) ->
|
||||||
|
case find_keyed(Cid, State) of
|
||||||
|
{ok, KindMap} ->
|
||||||
|
[{K, sum_counts(AM)} || {K, AM} <- KindMap];
|
||||||
|
_ -> []
|
||||||
|
end.
|
||||||
|
|
||||||
|
total_for(Cid, State) ->
|
||||||
|
lists:foldl(fun ({_, N}, Acc) -> N + Acc end, 0, counters_for(Cid, State)).
|
||||||
|
|
||||||
|
kinds_for(Cid, State) ->
|
||||||
|
[K || {K, _} <- counters_for(Cid, State)].
|
||||||
|
|
||||||
|
endorsers_for(Cid, Kind, State) ->
|
||||||
|
case find_keyed(Cid, State) of
|
||||||
|
{ok, KindMap} ->
|
||||||
|
case find_keyed(Kind, KindMap) of
|
||||||
|
{ok, AM} -> [A || {A, _} <- AM];
|
||||||
|
_ -> []
|
||||||
|
end;
|
||||||
|
_ -> []
|
||||||
|
end.
|
||||||
|
|
||||||
|
has_endorsed(Actor, Cid, Kind, State) ->
|
||||||
|
case find_keyed(Cid, State) of
|
||||||
|
{ok, KindMap} ->
|
||||||
|
case find_keyed(Kind, KindMap) of
|
||||||
|
{ok, AM} ->
|
||||||
|
case find_keyed(Actor, AM) of
|
||||||
|
{ok, N} -> N > 0;
|
||||||
|
_ -> false
|
||||||
|
end;
|
||||||
|
_ -> false
|
||||||
|
end;
|
||||||
|
_ -> false
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── Internal ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
sum_counts([]) -> 0;
|
||||||
|
sum_counts([{_, N} | Rest]) -> N + sum_counts(Rest).
|
||||||
|
|
||||||
|
find_keyed(_, []) -> {error, not_found};
|
||||||
|
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||||
|
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||||
|
|
||||||
|
set_keyed(K, V, []) -> [{K, V}];
|
||||||
|
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||||
177
next/kernel/envelope.erl
Normal file
177
next/kernel/envelope.erl
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
-module(envelope).
|
||||||
|
-export([validate_shape/1, get_field/2, canonical_bytes/1, verify_signature/2]).
|
||||||
|
|
||||||
|
%% Activity envelope per design §3.1.
|
||||||
|
%%
|
||||||
|
%% Erlang maps (#{...}) are not supported by this port, so envelopes
|
||||||
|
%% are represented as property lists of {atom_key, value} pairs. This
|
||||||
|
%% port's binary syntax also can't carry string literals; values that
|
||||||
|
%% would naturally be binaries in real Erlang are kept as atoms or
|
||||||
|
%% integer-segment binaries in the test corpus.
|
||||||
|
%%
|
||||||
|
%% Required fields: id, type, actor, published, signature.
|
||||||
|
%% The signature value is itself a property list with key_id,
|
||||||
|
%% algorithm, value.
|
||||||
|
%%
|
||||||
|
%% validate_shape/1 returns ok | {error, Reason}. Reasons:
|
||||||
|
%% not_a_proplist
|
||||||
|
%% {missing_field, FieldName}
|
||||||
|
%% {bad_signature, BadSigReason}
|
||||||
|
%%
|
||||||
|
%% get_field/2 returns {ok, Value} | not_found.
|
||||||
|
|
||||||
|
validate_shape(Env) when is_list(Env) ->
|
||||||
|
case check_required([id, type, actor, published, signature], Env) of
|
||||||
|
ok -> validate_signature_shape(Env);
|
||||||
|
Err -> Err
|
||||||
|
end;
|
||||||
|
validate_shape(_) ->
|
||||||
|
{error, not_a_proplist}.
|
||||||
|
|
||||||
|
get_field(_, []) -> not_found;
|
||||||
|
get_field(K, [{K, V} | _]) -> {ok, V};
|
||||||
|
get_field(K, [_ | Rest]) -> get_field(K, Rest).
|
||||||
|
|
||||||
|
check_required([], _) -> ok;
|
||||||
|
check_required([F | Rest], Env) ->
|
||||||
|
case get_field(F, Env) of
|
||||||
|
{ok, _} -> check_required(Rest, Env);
|
||||||
|
not_found -> {error, {missing_field, F}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
validate_signature_shape(Env) ->
|
||||||
|
{ok, Sig} = get_field(signature, Env),
|
||||||
|
case is_list(Sig) of
|
||||||
|
true ->
|
||||||
|
case check_required([key_id, algorithm, value], Sig) of
|
||||||
|
ok -> ok;
|
||||||
|
{error, {missing_field, F}} ->
|
||||||
|
{error, {bad_signature, {missing_field, F}}}
|
||||||
|
end;
|
||||||
|
false ->
|
||||||
|
{error, {bad_signature, not_a_proplist}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% canonical_bytes/1 — the byte string the signature covers.
|
||||||
|
%%
|
||||||
|
%% Real fed-sx will use dag-cbor over a JSON-LD-canonicalised form
|
||||||
|
%% (design §3.2). For milestone 1 we stand in for that with the host
|
||||||
|
%% BIF `cid:to_string/1`, which produces a CIDv1 over the deterministic
|
||||||
|
%% textual form of the term. Two prior steps make this work:
|
||||||
|
%% 1. The signature pair is stripped (sig covers everything except
|
||||||
|
%% itself).
|
||||||
|
%% 2. The top-level property list is sorted by key so field order in
|
||||||
|
%% the source envelope is not load-bearing.
|
||||||
|
%%
|
||||||
|
%% The result is an Erlang binary suitable as the sig-cover input.
|
||||||
|
|
||||||
|
canonical_bytes(Env) when is_list(Env) ->
|
||||||
|
Stripped = strip_signature(Env),
|
||||||
|
Sorted = sort_pairs(Stripped),
|
||||||
|
cid:to_string(Sorted).
|
||||||
|
|
||||||
|
strip_signature([]) -> [];
|
||||||
|
strip_signature([{signature, _} | Rest]) -> strip_signature(Rest);
|
||||||
|
strip_signature([P | Rest]) -> [P | strip_signature(Rest)].
|
||||||
|
|
||||||
|
sort_pairs([]) -> [];
|
||||||
|
sort_pairs([H | T]) -> insert_pair(H, sort_pairs(T)).
|
||||||
|
|
||||||
|
insert_pair(P, []) -> [P];
|
||||||
|
insert_pair({K1, V1}, [{K2, V2} | Rest]) ->
|
||||||
|
case K1 < K2 of
|
||||||
|
true -> [{K1, V1}, {K2, V2} | Rest];
|
||||||
|
false -> [{K2, V2} | insert_pair({K1, V1}, Rest)]
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% verify_signature/2 — time-aware sig verification per design §9.6.
|
||||||
|
%%
|
||||||
|
%% Activity carries a `signature` proplist with `key_id`, `algorithm`,
|
||||||
|
%% `value`. ActorState carries `public_keys` — a list of key proplists
|
||||||
|
%% with `id`, `created`, optionally `superseded_at`, and `value` (the
|
||||||
|
%% key material).
|
||||||
|
%%
|
||||||
|
%% A key is active at time T iff `created =< T` AND
|
||||||
|
%% (no `superseded_at` OR T < `superseded_at`). Verification picks the
|
||||||
|
%% first matching active key whose `id == signature.key_id` at the
|
||||||
|
%% activity's `published` timestamp, then recomputes the MAC
|
||||||
|
%% `crypto:hash(sha256, <<KeyMaterial/binary, CanonicalBytes/binary>>)`
|
||||||
|
%% and compares it to `signature.value`.
|
||||||
|
%%
|
||||||
|
%% Returns ok | {error, Reason}. Reasons:
|
||||||
|
%% no_signature | no_key_id | no_published | no_keys |
|
||||||
|
%% no_active_key | bad_signature
|
||||||
|
%%
|
||||||
|
%% Real RSA-SHA256 / Ed25519 verification is deferred to milestone 2:
|
||||||
|
%% Phase 8 only ships `crypto:hash/2`, so we stand in with an HMAC-shaped
|
||||||
|
%% MAC that exercises the same key-lookup and canonical-bytes pipeline.
|
||||||
|
|
||||||
|
verify_signature(Activity, ActorState) ->
|
||||||
|
case get_field(signature, Activity) of
|
||||||
|
not_found -> {error, no_signature};
|
||||||
|
{ok, Sig} ->
|
||||||
|
case get_field(key_id, Sig) of
|
||||||
|
not_found -> {error, no_key_id};
|
||||||
|
{ok, KeyId} ->
|
||||||
|
case get_field(published, Activity) of
|
||||||
|
not_found -> {error, no_published};
|
||||||
|
{ok, Published} ->
|
||||||
|
verify_with_keys(Activity, Sig, KeyId,
|
||||||
|
Published, ActorState)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
verify_with_keys(Activity, Sig, KeyId, Published, ActorState) ->
|
||||||
|
case get_field(public_keys, ActorState) of
|
||||||
|
not_found -> {error, no_keys};
|
||||||
|
{ok, Keys} ->
|
||||||
|
case find_active_key(KeyId, Published, Keys) of
|
||||||
|
not_found -> {error, no_active_key};
|
||||||
|
{ok, Key} -> verify_mac(Activity, Sig, Key)
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
find_active_key(_, _, []) -> not_found;
|
||||||
|
find_active_key(KeyId, Now, [Key | Rest]) ->
|
||||||
|
case is_matching_active_key(Key, KeyId, Now) of
|
||||||
|
true -> {ok, Key};
|
||||||
|
false -> find_active_key(KeyId, Now, Rest)
|
||||||
|
end.
|
||||||
|
|
||||||
|
is_matching_active_key(Key, WantId, Now) ->
|
||||||
|
case get_field(id, Key) of
|
||||||
|
{ok, WantId} -> is_active_at(Key, Now);
|
||||||
|
_ -> false
|
||||||
|
end.
|
||||||
|
|
||||||
|
is_active_at(Key, Now) ->
|
||||||
|
case get_field(created, Key) of
|
||||||
|
not_found -> false;
|
||||||
|
{ok, Created} ->
|
||||||
|
case Now >= Created of
|
||||||
|
false -> false;
|
||||||
|
true ->
|
||||||
|
case get_field(superseded_at, Key) of
|
||||||
|
not_found -> true;
|
||||||
|
{ok, SupAt} -> Now < SupAt
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
verify_mac(Activity, Sig, Key) ->
|
||||||
|
case get_field(value, Sig) of
|
||||||
|
not_found -> {error, bad_signature};
|
||||||
|
{ok, SigValue} ->
|
||||||
|
case get_field(value, Key) of
|
||||||
|
not_found -> {error, bad_signature};
|
||||||
|
{ok, KeyMat} ->
|
||||||
|
Bytes = canonical_bytes(Activity),
|
||||||
|
Computed = crypto:hash(sha256,
|
||||||
|
<<KeyMat/binary, Bytes/binary>>),
|
||||||
|
case SigValue =:= Computed of
|
||||||
|
true -> ok;
|
||||||
|
false -> {error, bad_signature}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
237
next/kernel/follower_graph.erl
Normal file
237
next/kernel/follower_graph.erl
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
-module(follower_graph).
|
||||||
|
-export([fold/2, fold_fn/0, new/0, lookup/2, actors/1,
|
||||||
|
following/2, followers/2,
|
||||||
|
pending_outbound/2, pending_inbound/2,
|
||||||
|
is_following/3, has_follower/3,
|
||||||
|
is_pending_outbound/3, is_pending_inbound/3]).
|
||||||
|
|
||||||
|
%% Follower-graph projection — Erlang-fun stand-in for the genesis
|
||||||
|
%% `follower-graph.sx` body. Tracks per-actor follow relationships
|
||||||
|
%% per design §13.2:
|
||||||
|
%%
|
||||||
|
%% Follow {actor: A, object: B} A asks to follow B
|
||||||
|
%% Accept {actor: B, object: F} B accepts A's Follow F (= F.actor → F.object)
|
||||||
|
%% Reject {actor: B, object: F} B rejects A's Follow F
|
||||||
|
%% Undo {actor: A, object: F} A retracts F or unfollows
|
||||||
|
%%
|
||||||
|
%% Where F = Follow{A→B} is embedded as the activity's :object
|
||||||
|
%% proplist for Accept / Reject / Undo.
|
||||||
|
%%
|
||||||
|
%% State shape:
|
||||||
|
%% [{ActorId, ActorEntry}, ...]
|
||||||
|
%%
|
||||||
|
%% ActorEntry = [{following, [PeerId, ...]},
|
||||||
|
%% {followers, [PeerId, ...]},
|
||||||
|
%% {pending_outbound, [PeerId, ...]}, %% I asked, no answer yet
|
||||||
|
%% {pending_inbound, [PeerId, ...]}] %% asked me, I haven't answered
|
||||||
|
%%
|
||||||
|
%% Sets keep insertion order; duplicates aren't added. lists:keyfind/
|
||||||
|
%% keymember aren't in this substrate, so local find_keyed/has_keyed/
|
||||||
|
%% set_keyed helpers (same convention as actor_state, define_registry,
|
||||||
|
%% nx_kernel).
|
||||||
|
|
||||||
|
%% ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
new() -> [].
|
||||||
|
|
||||||
|
actors(State) -> [Id || {Id, _Entry} <- State].
|
||||||
|
|
||||||
|
lookup(ActorId, State) ->
|
||||||
|
case find_keyed(ActorId, State) of
|
||||||
|
{ok, Entry} -> {ok, Entry};
|
||||||
|
_ -> not_found
|
||||||
|
end.
|
||||||
|
|
||||||
|
following(ActorId, State) -> entry_field(ActorId, following, State).
|
||||||
|
followers(ActorId, State) -> entry_field(ActorId, followers, State).
|
||||||
|
pending_outbound(ActorId, State) -> entry_field(ActorId, pending_outbound, State).
|
||||||
|
pending_inbound(ActorId, State) -> entry_field(ActorId, pending_inbound, State).
|
||||||
|
|
||||||
|
is_following(ActorId, PeerId, State) ->
|
||||||
|
contains(PeerId, following(ActorId, State)).
|
||||||
|
|
||||||
|
has_follower(ActorId, PeerId, State) ->
|
||||||
|
contains(PeerId, followers(ActorId, State)).
|
||||||
|
|
||||||
|
is_pending_outbound(ActorId, PeerId, State) ->
|
||||||
|
contains(PeerId, pending_outbound(ActorId, State)).
|
||||||
|
|
||||||
|
is_pending_inbound(ActorId, PeerId, State) ->
|
||||||
|
contains(PeerId, pending_inbound(ActorId, State)).
|
||||||
|
|
||||||
|
%% ── Fold dispatch ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
fold(Activity, State) ->
|
||||||
|
case envelope:get_field(type, Activity) of
|
||||||
|
{ok, follow} -> fold_follow(Activity, State);
|
||||||
|
{ok, accept} -> fold_accept(Activity, State);
|
||||||
|
{ok, reject} -> fold_reject(Activity, State);
|
||||||
|
{ok, undo} -> fold_undo(Activity, State);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
fold_fn() ->
|
||||||
|
fun (Activity, State) -> fold(Activity, State) end.
|
||||||
|
|
||||||
|
%% Follow {actor: A, object: B}:
|
||||||
|
%% add B to A's pending_outbound
|
||||||
|
%% add A to B's pending_inbound
|
||||||
|
fold_follow(Activity, State) ->
|
||||||
|
case follow_actor_object(Activity) of
|
||||||
|
{ok, A, B} when A =/= B ->
|
||||||
|
S1 = add_to_field(A, pending_outbound, B, State),
|
||||||
|
add_to_field(B, pending_inbound, A, S1);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Accept {actor: B, object: Follow{A→B}}:
|
||||||
|
%% move A from B's pending_inbound to B's followers
|
||||||
|
%% move B from A's pending_outbound to A's following
|
||||||
|
fold_accept(Activity, State) ->
|
||||||
|
case nested_follow_actor_object(Activity) of
|
||||||
|
{ok, B, A, OrigA, OrigB} when B =:= OrigB, A =:= OrigA, A =/= B ->
|
||||||
|
S1 = move_field(B, pending_inbound, followers, A, State),
|
||||||
|
move_field(A, pending_outbound, following, B, S1);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Reject {actor: B, object: Follow{A→B}}:
|
||||||
|
%% drop A from B's pending_inbound
|
||||||
|
%% drop B from A's pending_outbound
|
||||||
|
fold_reject(Activity, State) ->
|
||||||
|
case nested_follow_actor_object(Activity) of
|
||||||
|
{ok, B, A, OrigA, OrigB} when B =:= OrigB, A =:= OrigA, A =/= B ->
|
||||||
|
S1 = drop_from_field(B, pending_inbound, A, State),
|
||||||
|
drop_from_field(A, pending_outbound, B, S1);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Undo {actor: X, object: Follow{A→B}}:
|
||||||
|
%% Only the original Follow's actor (A) can Undo it.
|
||||||
|
%% Drops A↔B from every list on either side.
|
||||||
|
fold_undo(Activity, State) ->
|
||||||
|
case nested_follow_actor_object(Activity) of
|
||||||
|
{ok, X, OrigA, OrigA, OrigB} when X =:= OrigA, OrigA =/= OrigB ->
|
||||||
|
S1 = drop_from_field(OrigA, following, OrigB, State),
|
||||||
|
S2 = drop_from_field(OrigA, pending_outbound, OrigB, S1),
|
||||||
|
S3 = drop_from_field(OrigB, followers, OrigA, S2),
|
||||||
|
drop_from_field(OrigB, pending_inbound, OrigA, S3);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── Extraction helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
follow_actor_object(Activity) ->
|
||||||
|
case envelope:get_field(actor, Activity) of
|
||||||
|
{ok, A} ->
|
||||||
|
case envelope:get_field(object, Activity) of
|
||||||
|
{ok, B} when is_atom(B) -> {ok, A, B};
|
||||||
|
_ -> not_follow
|
||||||
|
end;
|
||||||
|
_ -> not_follow
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% nested_follow_actor_object/1 — pull (Actor, FollowActor, FollowObject)
|
||||||
|
%% out of an envelope whose :object is itself a Follow proplist.
|
||||||
|
%% Returns {ok, OuterActor, InferredPeer, InnerActor, InnerObject}.
|
||||||
|
nested_follow_actor_object(Activity) ->
|
||||||
|
case envelope:get_field(actor, Activity) of
|
||||||
|
{ok, Outer} ->
|
||||||
|
case envelope:get_field(object, Activity) of
|
||||||
|
{ok, Inner} when is_list(Inner) ->
|
||||||
|
case nested_is_follow(Inner) of
|
||||||
|
true ->
|
||||||
|
case {envelope:get_field(actor, Inner),
|
||||||
|
envelope:get_field(object, Inner)} of
|
||||||
|
{{ok, IA}, {ok, IO}} when is_atom(IO) ->
|
||||||
|
{ok, Outer, peer_from_inner(Outer, IA, IO), IA, IO};
|
||||||
|
_ -> not_a_follow_wrapper
|
||||||
|
end;
|
||||||
|
false -> not_a_follow_wrapper
|
||||||
|
end;
|
||||||
|
_ -> not_a_follow_wrapper
|
||||||
|
end;
|
||||||
|
_ -> not_a_follow_wrapper
|
||||||
|
end.
|
||||||
|
|
||||||
|
nested_is_follow(Inner) ->
|
||||||
|
case envelope:get_field(type, Inner) of
|
||||||
|
{ok, follow} -> true;
|
||||||
|
_ -> false
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% peer_from_inner — for an Accept/Reject by B of Follow{A→B},
|
||||||
|
%% Outer = B; the "peer" we move state for is A. For an Undo by A,
|
||||||
|
%% Outer = A; the peer is B. Picking the inner actor/object that
|
||||||
|
%% isn't Outer gives us the right pair-mate.
|
||||||
|
peer_from_inner(Outer, IA, _IO) when Outer =:= IA -> IA;
|
||||||
|
peer_from_inner(_Outer, IA, _IO) -> IA.
|
||||||
|
|
||||||
|
%% ── Entry / field accessors ────────────────────────────────────
|
||||||
|
|
||||||
|
entry_field(ActorId, Field, State) ->
|
||||||
|
case find_keyed(ActorId, State) of
|
||||||
|
{ok, Entry} ->
|
||||||
|
case find_keyed(Field, Entry) of
|
||||||
|
{ok, Val} -> Val;
|
||||||
|
_ -> []
|
||||||
|
end;
|
||||||
|
_ -> []
|
||||||
|
end.
|
||||||
|
|
||||||
|
empty_entry() ->
|
||||||
|
[{following, []},
|
||||||
|
{followers, []},
|
||||||
|
{pending_outbound, []},
|
||||||
|
{pending_inbound, []}].
|
||||||
|
|
||||||
|
ensure_entry(ActorId, State) ->
|
||||||
|
case find_keyed(ActorId, State) of
|
||||||
|
{ok, _} -> State;
|
||||||
|
_ -> State ++ [{ActorId, empty_entry()}]
|
||||||
|
end.
|
||||||
|
|
||||||
|
add_to_field(ActorId, Field, PeerId, State) ->
|
||||||
|
S1 = ensure_entry(ActorId, State),
|
||||||
|
{ok, Entry} = find_keyed(ActorId, S1),
|
||||||
|
Current = entry_field(ActorId, Field, S1),
|
||||||
|
NewList = case contains(PeerId, Current) of
|
||||||
|
true -> Current;
|
||||||
|
false -> Current ++ [PeerId]
|
||||||
|
end,
|
||||||
|
NewEntry = set_keyed(Field, NewList, Entry),
|
||||||
|
set_keyed(ActorId, NewEntry, S1).
|
||||||
|
|
||||||
|
drop_from_field(ActorId, Field, PeerId, State) ->
|
||||||
|
case find_keyed(ActorId, State) of
|
||||||
|
{ok, Entry} ->
|
||||||
|
Current = entry_field(ActorId, Field, State),
|
||||||
|
NewList = remove_member(PeerId, Current),
|
||||||
|
NewEntry = set_keyed(Field, NewList, Entry),
|
||||||
|
set_keyed(ActorId, NewEntry, State);
|
||||||
|
_ -> State
|
||||||
|
end.
|
||||||
|
|
||||||
|
move_field(ActorId, FromField, ToField, PeerId, State) ->
|
||||||
|
S1 = drop_from_field(ActorId, FromField, PeerId, State),
|
||||||
|
add_to_field(ActorId, ToField, PeerId, S1).
|
||||||
|
|
||||||
|
%% ── List helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
contains(_, []) -> false;
|
||||||
|
contains(X, [X | _]) -> true;
|
||||||
|
contains(X, [_ | Rest]) -> contains(X, Rest).
|
||||||
|
|
||||||
|
remove_member(_, []) -> [];
|
||||||
|
remove_member(X, [X | Rest]) -> remove_member(X, Rest);
|
||||||
|
remove_member(X, [Y | Rest]) -> [Y | remove_member(X, Rest)].
|
||||||
|
|
||||||
|
%% ── Keyed-list helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
|
find_keyed(_, []) -> {error, not_found};
|
||||||
|
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||||
|
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||||
|
|
||||||
|
set_keyed(K, V, []) -> [{K, V}];
|
||||||
|
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||||
1445
next/kernel/http_server.erl
Normal file
1445
next/kernel/http_server.erl
Normal file
File diff suppressed because it is too large
Load Diff
362
next/kernel/log.erl
Normal file
362
next/kernel/log.erl
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
-module(log).
|
||||||
|
-export([open/2, open_disk/2, open_disk/3,
|
||||||
|
append/2, tip/1, replay/3, entries/1,
|
||||||
|
segments/1]).
|
||||||
|
|
||||||
|
%% Per-actor activity log — the canonical record of everything an
|
||||||
|
%% actor has emitted, in chronological order. Per design §15.2 this
|
||||||
|
%% lives on disk as numbered segment files; v1 started with an
|
||||||
|
%% in-memory backend (Step 3a) so the API + seq-number machinery
|
||||||
|
%% could be locked down before on-disk persistence (Step 3b) and
|
||||||
|
%% segment rotation (Step 3c.a — this revision).
|
||||||
|
%%
|
||||||
|
%% On-disk layout:
|
||||||
|
%% <BasePath>/<ActorId>-NNNNNN.log
|
||||||
|
%%
|
||||||
|
%% NNNNNN is a 6-digit zero-padded segment index (000000..999999) so
|
||||||
|
%% file:list_dir's alphabetical ordering coincides with numeric. Each
|
||||||
|
%% segment file is the concat of length-prefixed frames; each frame
|
||||||
|
%% is `<<Len:32/big>>` + `term_codec:encode(Activity)`.
|
||||||
|
%%
|
||||||
|
%% In-memory state (a property list):
|
||||||
|
%% [{actor, ActorId},
|
||||||
|
%% {base, BasePath}, %% binary | charlist
|
||||||
|
%% {seq, NextSeq}, %% next seq the log will assign
|
||||||
|
%% {entries, [Activity, ...]}, %% flat, append order, oldest first
|
||||||
|
%% {persisted, true|false}, %% does append write through?
|
||||||
|
%% {seg_size, MaxBytes}, %% rotate when active segment > this
|
||||||
|
%% {seg_lens, [N0, N1, ...]}] %% entry count per segment in order
|
||||||
|
%%
|
||||||
|
%% `seg_lens` is the sole bookkeeping needed to compute (a) which
|
||||||
|
%% segment any given seq lives in, and (b) which slice of `entries`
|
||||||
|
%% is the active segment's contents to rewrite on append. The last
|
||||||
|
%% element is the active segment's length.
|
||||||
|
|
||||||
|
%% In-memory only — atoms accepted as BasePath for back-compat with
|
||||||
|
%% Step 3a tests that just want the API surface.
|
||||||
|
open(ActorId, BasePath) ->
|
||||||
|
{ok, [{actor, ActorId}, {base, BasePath},
|
||||||
|
{seq, 0}, {entries, []},
|
||||||
|
{persisted, false}]}.
|
||||||
|
|
||||||
|
%% Disk-backed; default segment size = effectively unlimited (no
|
||||||
|
%% rotation). Use open_disk/3 with {segment_size, N} to enable.
|
||||||
|
open_disk(ActorId, BasePath) ->
|
||||||
|
open_disk(ActorId, BasePath, [{segment_size, 1073741824}]). %% 1 GiB
|
||||||
|
|
||||||
|
open_disk(ActorId, BasePath, Opts) ->
|
||||||
|
SegSize = proplist_get(segment_size, Opts, 1073741824),
|
||||||
|
case load_all_segments(ActorId, BasePath) of
|
||||||
|
{ok, SegEntries} ->
|
||||||
|
%% SegEntries :: [[Entry, ...]] in segment-index order
|
||||||
|
%% (empty list when no segments exist on disk).
|
||||||
|
Lens0 = [length(S) || S <- SegEntries],
|
||||||
|
%% Always have at least one active segment, even if empty.
|
||||||
|
Lens = case Lens0 of
|
||||||
|
[] -> [0];
|
||||||
|
_ -> Lens0
|
||||||
|
end,
|
||||||
|
Flat = flatten_segs(SegEntries),
|
||||||
|
State = [{actor, ActorId}, {base, BasePath},
|
||||||
|
{seq, length(Flat)},
|
||||||
|
{entries, Flat},
|
||||||
|
{persisted, true},
|
||||||
|
{seg_size, SegSize},
|
||||||
|
{seg_lens, Lens}],
|
||||||
|
{ok, State};
|
||||||
|
{error, _} = E ->
|
||||||
|
E
|
||||||
|
end.
|
||||||
|
|
||||||
|
append(LogState, Activity) ->
|
||||||
|
Seq = field(seq, LogState),
|
||||||
|
Entries = field(entries, LogState),
|
||||||
|
case lookup(persisted, LogState) of
|
||||||
|
true ->
|
||||||
|
SegLens = field(seg_lens, LogState),
|
||||||
|
SegSize = field(seg_size, LogState),
|
||||||
|
{NewSegLens, ActiveIdx, ActiveEntries} =
|
||||||
|
place_append(Entries, Activity, SegLens, SegSize),
|
||||||
|
Path = segment_path(field(actor, LogState),
|
||||||
|
field(base, LogState),
|
||||||
|
ActiveIdx),
|
||||||
|
ok = write_segment(Path, ActiveEntries),
|
||||||
|
NewState = replace_field(seq, Seq + 1,
|
||||||
|
replace_field(entries, Entries ++ [Activity],
|
||||||
|
replace_field(seg_lens, NewSegLens, LogState))),
|
||||||
|
{ok, NewState, Seq};
|
||||||
|
_ ->
|
||||||
|
NewState = replace_field(seq, Seq + 1,
|
||||||
|
replace_field(entries, Entries ++ [Activity],
|
||||||
|
LogState)),
|
||||||
|
{ok, NewState, Seq}
|
||||||
|
end.
|
||||||
|
|
||||||
|
tip(LogState) ->
|
||||||
|
field(seq, LogState).
|
||||||
|
|
||||||
|
replay(LogState, InitAcc, Fun) ->
|
||||||
|
Entries = field(entries, LogState),
|
||||||
|
replay_loop(Entries, 0, InitAcc, Fun).
|
||||||
|
|
||||||
|
entries(LogState) ->
|
||||||
|
field(entries, LogState).
|
||||||
|
|
||||||
|
%% Debug accessor: returns the in-memory seg_lens (count per segment
|
||||||
|
%% in index order). Used by rotation tests to assert that rotation
|
||||||
|
%% happened.
|
||||||
|
segments(LogState) ->
|
||||||
|
case lookup(seg_lens, LogState) of
|
||||||
|
undefined -> [];
|
||||||
|
L -> L
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% --- internals ---
|
||||||
|
|
||||||
|
replay_loop([], _, Acc, _) -> Acc;
|
||||||
|
replay_loop([Act | Rest], Seq, Acc, Fun) ->
|
||||||
|
replay_loop(Rest, Seq + 1, Fun(Act, Seq, Acc), Fun).
|
||||||
|
|
||||||
|
%% place_append/4 decides whether the new Activity extends the current
|
||||||
|
%% active segment or opens a fresh one, returning the resulting
|
||||||
|
%% seg_lens, the active segment's index, and the active segment's
|
||||||
|
%% complete entry list (the slice that needs to be (re)written to
|
||||||
|
%% disk).
|
||||||
|
%%
|
||||||
|
%% Rotation rule: if the active segment already on disk is at or past
|
||||||
|
%% the size threshold (encoded_size(OldActive) >= SegSize) AND it
|
||||||
|
%% already holds at least one entry, the new Activity opens a new
|
||||||
|
%% segment. A single entry larger than the threshold therefore lives
|
||||||
|
%% on its own — we never recurse rotating a one-entry segment.
|
||||||
|
%%
|
||||||
|
%% This is decided BEFORE the append (looking at the pre-append size),
|
||||||
|
%% so each segment file is written exactly once per append cycle.
|
||||||
|
place_append(OldEntries, Activity, SegLens, SegSize) ->
|
||||||
|
{Pre, Last} = split_last(SegLens),
|
||||||
|
PreCount = sum(Pre),
|
||||||
|
OldActive = drop(PreCount, OldEntries),
|
||||||
|
OldActiveSize = encoded_size(OldActive),
|
||||||
|
case (OldActiveSize >= SegSize) andalso (Last >= 1) of
|
||||||
|
true ->
|
||||||
|
%% Rotate: new entry starts a brand-new segment.
|
||||||
|
NewSegLens = SegLens ++ [1],
|
||||||
|
NewActiveIdx = length(SegLens),
|
||||||
|
{NewSegLens, NewActiveIdx, [Activity]};
|
||||||
|
false ->
|
||||||
|
%% Stay: extend current active.
|
||||||
|
NewSegLens = Pre ++ [Last + 1],
|
||||||
|
NewActiveIdx = length(Pre),
|
||||||
|
{NewSegLens, NewActiveIdx, OldActive ++ [Activity]}
|
||||||
|
end.
|
||||||
|
|
||||||
|
split_last([X]) -> {[], X};
|
||||||
|
split_last([H | T]) ->
|
||||||
|
{Tl, Last} = split_last(T),
|
||||||
|
{[H | Tl], Last}.
|
||||||
|
|
||||||
|
sum(L) -> sum_(L, 0).
|
||||||
|
sum_([], A) -> A;
|
||||||
|
sum_([H | T], A) -> sum_(T, A + H).
|
||||||
|
|
||||||
|
drop(0, L) -> L;
|
||||||
|
drop(_, []) -> [];
|
||||||
|
drop(N, [_ | T]) -> drop(N - 1, T).
|
||||||
|
|
||||||
|
%% flatten_segs/1 — concat a list of segments (each itself a list of
|
||||||
|
%% entries) into a single flat list, preserving order. Used by
|
||||||
|
%% open_disk to assemble the on-disk activity history from per-
|
||||||
|
%% segment loads. Implemented locally because lists:append/1 isn't
|
||||||
|
%% registered in this port — only lists:append/2.
|
||||||
|
flatten_segs([]) -> [];
|
||||||
|
flatten_segs([Seg | Rest]) -> Seg ++ flatten_segs(Rest).
|
||||||
|
|
||||||
|
encoded_size(Entries) ->
|
||||||
|
byte_size(list_to_binary(
|
||||||
|
[frame(term_codec:encode(E)) || E <- Entries])).
|
||||||
|
|
||||||
|
%% Try to read every segment file under BasePath matching the actor.
|
||||||
|
%% Returns {ok, [[Entry, ...]]} where the outer list is in segment-
|
||||||
|
%% index order. Empty when no segments exist.
|
||||||
|
load_all_segments(ActorId, BasePath) ->
|
||||||
|
%% list_dir returns {ok, [Binary]} of entry names in sorted order
|
||||||
|
%% per fed-prims contract.
|
||||||
|
BaseChars = base_chars(BasePath),
|
||||||
|
case file:list_dir(BaseChars) of
|
||||||
|
{ok, Names} ->
|
||||||
|
%% Erlang string literals are NOT charlists in this port,
|
||||||
|
%% so build prefix/suffix as explicit char-code lists.
|
||||||
|
Prefix = atom_to_list(ActorId) ++ [$-],
|
||||||
|
Suffix = [$., $l, $o, $g],
|
||||||
|
Indices = collect_segment_indices(Names, Prefix, Suffix),
|
||||||
|
read_segments_in_order(Indices, ActorId, BasePath, []);
|
||||||
|
{error, enoent} ->
|
||||||
|
{ok, []};
|
||||||
|
{error, R} ->
|
||||||
|
{error, {read, R}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
collect_segment_indices([], _, _) -> [];
|
||||||
|
collect_segment_indices([Name | Rest], Prefix, Suffix) ->
|
||||||
|
case parse_segment_name(Name, Prefix, Suffix) of
|
||||||
|
{ok, N} ->
|
||||||
|
[N | collect_segment_indices(Rest, Prefix, Suffix)];
|
||||||
|
not_ours ->
|
||||||
|
collect_segment_indices(Rest, Prefix, Suffix)
|
||||||
|
end.
|
||||||
|
|
||||||
|
parse_segment_name(NameBin, Prefix, Suffix) when is_binary(NameBin) ->
|
||||||
|
parse_segment_name(binary_to_list(NameBin), Prefix, Suffix);
|
||||||
|
parse_segment_name(Name, Prefix, Suffix) ->
|
||||||
|
case strip_prefix(Name, Prefix) of
|
||||||
|
{ok, Rest} ->
|
||||||
|
case strip_suffix(Rest, Suffix) of
|
||||||
|
{ok, NumStr} ->
|
||||||
|
case is_all_digits(NumStr) of
|
||||||
|
true -> {ok, list_to_integer(NumStr)};
|
||||||
|
false -> not_ours
|
||||||
|
end;
|
||||||
|
not_ours -> not_ours
|
||||||
|
end;
|
||||||
|
not_ours -> not_ours
|
||||||
|
end.
|
||||||
|
|
||||||
|
strip_prefix(Str, []) -> {ok, Str};
|
||||||
|
strip_prefix([C | Rest], [P | PRest]) ->
|
||||||
|
case C =:= P of
|
||||||
|
true -> strip_prefix(Rest, PRest);
|
||||||
|
false -> not_ours
|
||||||
|
end;
|
||||||
|
strip_prefix(_, _) -> not_ours.
|
||||||
|
|
||||||
|
strip_suffix(Str, Suffix) ->
|
||||||
|
SL = length(Str),
|
||||||
|
XL = length(Suffix),
|
||||||
|
case SL >= XL of
|
||||||
|
true ->
|
||||||
|
Head = take_n_pl(SL - XL, Str),
|
||||||
|
Tail = drop(SL - XL, Str),
|
||||||
|
case Tail =:= Suffix of
|
||||||
|
true -> {ok, Head};
|
||||||
|
false -> not_ours
|
||||||
|
end;
|
||||||
|
false -> not_ours
|
||||||
|
end.
|
||||||
|
|
||||||
|
take_n_pl(0, _) -> [];
|
||||||
|
take_n_pl(_, []) -> [];
|
||||||
|
take_n_pl(N, [H | T]) -> [H | take_n_pl(N - 1, T)].
|
||||||
|
|
||||||
|
is_all_digits([]) -> false;
|
||||||
|
is_all_digits(Chars) -> all_digits(Chars).
|
||||||
|
|
||||||
|
all_digits([]) -> true;
|
||||||
|
all_digits([C | Rest]) when C >= $0, C =< $9 -> all_digits(Rest);
|
||||||
|
all_digits(_) -> false.
|
||||||
|
|
||||||
|
%% read_segments_in_order/4 — fed-prims sorts list_dir alphabetically;
|
||||||
|
%% with 6-digit zero-padded names that coincides with numeric order.
|
||||||
|
%% But we also accept legacy unpadded names, so sort by index to be
|
||||||
|
%% defensive.
|
||||||
|
read_segments_in_order(Indices, ActorId, BasePath, Acc) ->
|
||||||
|
Sorted = isort(Indices),
|
||||||
|
read_each(Sorted, ActorId, BasePath, Acc).
|
||||||
|
|
||||||
|
read_each([], _, _, Acc) ->
|
||||||
|
{ok, lists:reverse(Acc)};
|
||||||
|
read_each([Idx | Rest], ActorId, BasePath, Acc) ->
|
||||||
|
Path = segment_path(ActorId, BasePath, Idx),
|
||||||
|
case try_read_segment(Path) of
|
||||||
|
{ok, Entries} ->
|
||||||
|
read_each(Rest, ActorId, BasePath, [Entries | Acc]);
|
||||||
|
{error, _} = E -> E
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Tiny insertion sort over a small list of integers.
|
||||||
|
isort([]) -> [];
|
||||||
|
isort([H | T]) -> insert(H, isort(T)).
|
||||||
|
insert(X, []) -> [X];
|
||||||
|
insert(X, [Y | Rest]) when X =< Y -> [X, Y | Rest];
|
||||||
|
insert(X, [Y | Rest]) -> [Y | insert(X, Rest)].
|
||||||
|
|
||||||
|
%% segment_path/3 — charlist path to the Idx'th segment file.
|
||||||
|
segment_path(ActorId, BasePath, Idx) ->
|
||||||
|
base_chars(BasePath) ++ [$/] ++ atom_to_list(ActorId)
|
||||||
|
++ [$-] ++ pad_int(Idx, 6) ++ [$., $l, $o, $g].
|
||||||
|
|
||||||
|
base_chars(B) when is_binary(B) -> binary_to_list(B);
|
||||||
|
base_chars(L) when is_list(L) -> L.
|
||||||
|
|
||||||
|
%% Zero-pad an integer to Width digits as a charlist.
|
||||||
|
pad_int(N, Width) ->
|
||||||
|
Cs = integer_to_list(N),
|
||||||
|
pad_left(Cs, Width).
|
||||||
|
|
||||||
|
pad_left(Cs, Width) ->
|
||||||
|
case length(Cs) >= Width of
|
||||||
|
true -> Cs;
|
||||||
|
false -> pad_left([$0 | Cs], Width)
|
||||||
|
end.
|
||||||
|
|
||||||
|
write_segment(Path, Entries) ->
|
||||||
|
Frames = [frame(term_codec:encode(E)) || E <- Entries],
|
||||||
|
file:write_file(Path, list_to_binary(Frames)).
|
||||||
|
|
||||||
|
%% frame/1 — prepend 4-byte big-endian length to Payload.
|
||||||
|
frame(Payload) when is_binary(Payload) ->
|
||||||
|
L = byte_size(Payload),
|
||||||
|
B3 = (L div 16777216) rem 256,
|
||||||
|
B2 = (L div 65536) rem 256,
|
||||||
|
B1 = (L div 256) rem 256,
|
||||||
|
B0 = L rem 256,
|
||||||
|
[B3, B2, B1, B0, Payload].
|
||||||
|
|
||||||
|
try_read_segment(Path) ->
|
||||||
|
case file:read_file(Path) of
|
||||||
|
{ok, Bin} ->
|
||||||
|
try {ok, decode_frames(binary_to_list(Bin), [])}
|
||||||
|
catch
|
||||||
|
throw:Reason -> {error, {corrupt, Reason}};
|
||||||
|
error:Reason -> {error, {corrupt, Reason}}
|
||||||
|
end;
|
||||||
|
{error, enoent} ->
|
||||||
|
{ok, []};
|
||||||
|
{error, R} ->
|
||||||
|
{error, {read, R}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
decode_frames([], Acc) ->
|
||||||
|
lists:reverse(Acc);
|
||||||
|
decode_frames([B3, B2, B1, B0 | Rest], Acc) ->
|
||||||
|
Len = B3 * 16777216 + B2 * 65536 + B1 * 256 + B0,
|
||||||
|
{Payload, Rest2} = take_n(Len, Rest),
|
||||||
|
case term_codec:decode(list_to_binary(Payload)) of
|
||||||
|
{ok, Term, _} -> decode_frames(Rest2, [Term | Acc]);
|
||||||
|
{error, R} -> throw({decode, R})
|
||||||
|
end;
|
||||||
|
decode_frames(_, _) ->
|
||||||
|
throw(truncated_header).
|
||||||
|
|
||||||
|
take_n(0, R) -> {[], R};
|
||||||
|
take_n(N, [H | T]) ->
|
||||||
|
{Hs, Tl} = take_n(N - 1, T),
|
||||||
|
{[H | Hs], Tl};
|
||||||
|
take_n(_, []) ->
|
||||||
|
throw(truncated_body).
|
||||||
|
|
||||||
|
%% --- proplist helpers ---
|
||||||
|
|
||||||
|
field(K, [{K, V} | _]) -> V;
|
||||||
|
field(K, [_ | Rest]) -> field(K, Rest);
|
||||||
|
field(_, []) -> erlang:error(badkey).
|
||||||
|
|
||||||
|
lookup(K, [{K, V} | _]) -> V;
|
||||||
|
lookup(K, [_ | Rest]) -> lookup(K, Rest);
|
||||||
|
lookup(_, []) -> undefined.
|
||||||
|
|
||||||
|
replace_field(K, V, []) -> [{K, V}];
|
||||||
|
replace_field(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
replace_field(K, V, [P | Rest]) -> [P | replace_field(K, V, Rest)].
|
||||||
|
|
||||||
|
proplist_get(K, [{K, V} | _], _) -> V;
|
||||||
|
proplist_get(K, [_ | Rest], Default) -> proplist_get(K, Rest, Default);
|
||||||
|
proplist_get(_, [], Default) -> Default.
|
||||||
85
next/kernel/log_server.erl
Normal file
85
next/kernel/log_server.erl
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
-module(log_server).
|
||||||
|
-behaviour(gen_server).
|
||||||
|
-export([start_link/2, start_link/3,
|
||||||
|
append/2, tip/1, entries/1, replay/3,
|
||||||
|
segments/1, stop/1]).
|
||||||
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||||
|
|
||||||
|
%% Step 3c.b — gen_server in front of `log` that owns a single
|
||||||
|
%% per-actor disk-backed log state and serialises concurrent
|
||||||
|
%% appenders through `gen_server:call`.
|
||||||
|
%%
|
||||||
|
%% Architecture: the pure `log` module from Step 3c.a remains the
|
||||||
|
%% canonical substrate (open_disk, append, tip, replay, entries,
|
||||||
|
%% segments). This wrapper owns one log state per process; every
|
||||||
|
%% public op (append/tip/entries/replay/segments) routes through
|
||||||
|
%% gen_server:call so that the on-disk segment writer sees one
|
||||||
|
%% append at a time, regardless of how many writer processes are
|
||||||
|
%% pushing concurrently.
|
||||||
|
%%
|
||||||
|
%% Port notes carried from Step 5b's registry_server:
|
||||||
|
%% * `gen_server:start_link/2` returns the raw Pid, not `{ok,Pid}`.
|
||||||
|
%% * Spawned processes don't survive across separate
|
||||||
|
%% `erlang-eval-ast` invocations — every concurrency test has
|
||||||
|
%% to start the server, spin writers, join them, and assert all
|
||||||
|
%% within one eval expression.
|
||||||
|
%%
|
||||||
|
%% API takes the server Pid (not a registered name) so multiple
|
||||||
|
%% per-actor servers can coexist without colliding on the registry.
|
||||||
|
|
||||||
|
%% --- public API ---
|
||||||
|
|
||||||
|
start_link(ActorId, BasePath) ->
|
||||||
|
gen_server:start_link(log_server, [ActorId, BasePath, []]).
|
||||||
|
|
||||||
|
start_link(ActorId, BasePath, Opts) ->
|
||||||
|
gen_server:start_link(log_server, [ActorId, BasePath, Opts]).
|
||||||
|
|
||||||
|
append(Pid, Activity) ->
|
||||||
|
gen_server:call(Pid, {append, Activity}).
|
||||||
|
|
||||||
|
tip(Pid) ->
|
||||||
|
gen_server:call(Pid, tip).
|
||||||
|
|
||||||
|
entries(Pid) ->
|
||||||
|
gen_server:call(Pid, entries).
|
||||||
|
|
||||||
|
replay(Pid, InitAcc, Fun) ->
|
||||||
|
%% The fold runs server-side so the state stays consistent
|
||||||
|
%% with concurrent writers; the caller's Fun is closed over
|
||||||
|
%% the message and shipped opaque through gen_server:call.
|
||||||
|
gen_server:call(Pid, {replay, InitAcc, Fun}).
|
||||||
|
|
||||||
|
segments(Pid) ->
|
||||||
|
gen_server:call(Pid, segments).
|
||||||
|
|
||||||
|
stop(Pid) ->
|
||||||
|
gen_server:call(Pid, '$gen_stop').
|
||||||
|
|
||||||
|
%% --- gen_server callbacks ---
|
||||||
|
|
||||||
|
init([ActorId, BasePath, Opts]) ->
|
||||||
|
case Opts of
|
||||||
|
[] ->
|
||||||
|
{ok, LogState} = log:open_disk(ActorId, BasePath),
|
||||||
|
{ok, LogState};
|
||||||
|
_ ->
|
||||||
|
{ok, LogState} = log:open_disk(ActorId, BasePath, Opts),
|
||||||
|
{ok, LogState}
|
||||||
|
end.
|
||||||
|
|
||||||
|
handle_call({append, Activity}, _From, State) ->
|
||||||
|
{ok, NewState, Seq} = log:append(State, Activity),
|
||||||
|
{reply, {ok, Seq}, NewState};
|
||||||
|
handle_call(tip, _From, State) ->
|
||||||
|
{reply, log:tip(State), State};
|
||||||
|
handle_call(entries, _From, State) ->
|
||||||
|
{reply, log:entries(State), State};
|
||||||
|
handle_call({replay, InitAcc, Fun}, _From, State) ->
|
||||||
|
{reply, log:replay(State, InitAcc, Fun), State};
|
||||||
|
handle_call(segments, _From, State) ->
|
||||||
|
{reply, log:segments(State), State}.
|
||||||
|
|
||||||
|
handle_cast(_, S) -> {noreply, S}.
|
||||||
|
|
||||||
|
handle_info(_, S) -> {noreply, S}.
|
||||||
24
next/kernel/nx_cid.erl
Normal file
24
next/kernel/nx_cid.erl
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-module(nx_cid).
|
||||||
|
-export([from_sx/1, to_string/1, from_string/1, equals/2]).
|
||||||
|
|
||||||
|
%% The kernel-side CID wrapper. The host BIF `cid:to_string/1` already
|
||||||
|
%% produces a canonical CIDv1 (raw codec, sha2-256 multihash) over the
|
||||||
|
%% deterministic textual form of any term (er-format-value); we expose
|
||||||
|
%% it under the kernel namespace and add the equality + round-trip
|
||||||
|
%% helpers the rest of the kernel needs.
|
||||||
|
%%
|
||||||
|
%% Naming note: the BIF module is `cid`, so we use `nx_cid` to avoid
|
||||||
|
%% shadowing. Plans/fed-sx-milestone-1.md §Step 1 spells the file as
|
||||||
|
%% `cid.erl`; the briefing flags Erlang snippets as illustrative.
|
||||||
|
|
||||||
|
from_sx(V) ->
|
||||||
|
cid:to_string(V).
|
||||||
|
|
||||||
|
to_string(Cid) ->
|
||||||
|
Cid.
|
||||||
|
|
||||||
|
from_string(S) ->
|
||||||
|
S.
|
||||||
|
|
||||||
|
equals(A, B) ->
|
||||||
|
A =:= B.
|
||||||
451
next/kernel/nx_kernel.erl
Normal file
451
next/kernel/nx_kernel.erl
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
-module(nx_kernel).
|
||||||
|
-behaviour(gen_server).
|
||||||
|
|
||||||
|
%% Pure-functional API
|
||||||
|
-export([new/0, new/3,
|
||||||
|
add_actor/4, has_actor/2, actors/1, actor_count/1,
|
||||||
|
publish/2, publish/3,
|
||||||
|
bootstrap_actor/4,
|
||||||
|
actor_id/1, log_state/1, log_tip/1,
|
||||||
|
key_spec/1, actor_state/1, projections/1, next_published/1,
|
||||||
|
actor_log_state/2, actor_log_tip/2,
|
||||||
|
actor_inbox_state/2, actor_inbox_tip/2,
|
||||||
|
append_to_actor_inbox/3,
|
||||||
|
actor_key_spec/2, actor_state/2, actor_projections/2,
|
||||||
|
actor_next_published/2, actor_bucket/2,
|
||||||
|
with_projections/2, with_actor_projections/3,
|
||||||
|
next_actor_seq/1]).
|
||||||
|
|
||||||
|
%% gen_server API
|
||||||
|
-export([start_link/3, publish/1, query/0, log_tip/0,
|
||||||
|
with_projections/1, stop/0,
|
||||||
|
add_actor/3, publish_to/2, log_tip_for/1, log_state_for/1,
|
||||||
|
inbox_tip_for/1, inbox_state_for/1, append_inbox/2,
|
||||||
|
actors/0, state_for/1, bucket_for/1,
|
||||||
|
with_projections_for/2,
|
||||||
|
bootstrap_actor/3]).
|
||||||
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||||
|
|
||||||
|
%% Kernel orchestrator — the long-lived runtime state held by the
|
||||||
|
%% running fed-sx instance. Step 1 (m2) refactor: state is now
|
||||||
|
%% per-actor bucketed so one kernel hosts any number of actors.
|
||||||
|
%%
|
||||||
|
%% New state shape (property list):
|
||||||
|
%% [{actors, [{ActorId, ActorBucket}, ...]},
|
||||||
|
%% {next_actor_seq, NextN}]
|
||||||
|
%%
|
||||||
|
%% ActorBucket = [{key_spec, KS},
|
||||||
|
%% {actor_state, AS},
|
||||||
|
%% {log, L},
|
||||||
|
%% {projections, [Name]},
|
||||||
|
%% {next_published, NextSeq}]
|
||||||
|
%%
|
||||||
|
%% Legacy single-actor accessors (actor_id/1, key_spec/1, etc.)
|
||||||
|
%% continue to read from the first registered actor — keeps every
|
||||||
|
%% pre-m2 test passing through bootstrap:start/3.
|
||||||
|
%%
|
||||||
|
%% next_actor_seq is a monotonic counter handed out to add_actor for
|
||||||
|
%% future use (e.g. per-actor URL paths in Step 4). It's not yet
|
||||||
|
%% read by the rest of the kernel.
|
||||||
|
|
||||||
|
%% ── Pure-functional API ──────────────────────────────────────────
|
||||||
|
|
||||||
|
new() ->
|
||||||
|
[{actors, []}, {next_actor_seq, 1}].
|
||||||
|
|
||||||
|
new(ActorId, KeySpec, ActorStateProplist) ->
|
||||||
|
{ok, S} = add_actor(ActorId, KeySpec, ActorStateProplist, new()),
|
||||||
|
S.
|
||||||
|
|
||||||
|
add_actor(ActorId, KeySpec, AS, State) ->
|
||||||
|
Actors = field(actors, State),
|
||||||
|
case has_keyed(ActorId, Actors) of
|
||||||
|
true ->
|
||||||
|
{error, already_present};
|
||||||
|
false ->
|
||||||
|
{ok, L0} = log:open(ActorId, base_stub()),
|
||||||
|
{ok, I0} = log:open(ActorId, inbox_base_stub()),
|
||||||
|
Bucket = [{key_spec, KeySpec},
|
||||||
|
{actor_state, AS},
|
||||||
|
{log, L0},
|
||||||
|
{actor_inbox, I0},
|
||||||
|
{projections, []},
|
||||||
|
{next_published, 1}],
|
||||||
|
Seq = field(next_actor_seq, State),
|
||||||
|
State1 = set(actors, Actors ++ [{ActorId, Bucket}], State),
|
||||||
|
State2 = set(next_actor_seq, Seq + 1, State1),
|
||||||
|
{ok, State2}
|
||||||
|
end.
|
||||||
|
|
||||||
|
has_actor(ActorId, State) ->
|
||||||
|
has_keyed(ActorId, field(actors, State)).
|
||||||
|
|
||||||
|
actors(State) ->
|
||||||
|
[Id || {Id, _Bucket} <- field(actors, State)].
|
||||||
|
|
||||||
|
actor_count(State) ->
|
||||||
|
length(field(actors, State)).
|
||||||
|
|
||||||
|
next_actor_seq(State) ->
|
||||||
|
field(next_actor_seq, State).
|
||||||
|
|
||||||
|
actor_bucket(ActorId, State) ->
|
||||||
|
find_keyed(ActorId, field(actors, State)).
|
||||||
|
|
||||||
|
%% publish/3 — per-actor publish.
|
||||||
|
publish(ActorId, Request, State) ->
|
||||||
|
case actor_bucket(ActorId, State) of
|
||||||
|
{error, no_actor} ->
|
||||||
|
{error, no_actor, State};
|
||||||
|
{ok, Bucket} ->
|
||||||
|
P = field(next_published, Bucket),
|
||||||
|
Ctx = [{actor_id, ActorId},
|
||||||
|
{published, P},
|
||||||
|
{key_spec, field(key_spec, Bucket)},
|
||||||
|
{actor_state, field(actor_state, Bucket)},
|
||||||
|
{log, field(log, Bucket)},
|
||||||
|
{projections, field(projections, Bucket)}],
|
||||||
|
case outbox:publish(Request, Ctx) of
|
||||||
|
{ok, Result, NewLog} ->
|
||||||
|
B1 = set(log, NewLog, Bucket),
|
||||||
|
B2 = set(next_published, P + 1, B1),
|
||||||
|
NewState = set_bucket(ActorId, B2, State),
|
||||||
|
{ok, Result, NewState};
|
||||||
|
{error, Reason, _} ->
|
||||||
|
{error, Reason, State}
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% publish/2 — legacy single-actor publish; routes to first actor.
|
||||||
|
publish(Request, State) ->
|
||||||
|
case actors(State) of
|
||||||
|
[] -> {error, no_actor, State};
|
||||||
|
[First | _] -> publish(First, Request, State)
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% bootstrap_actor/4 — register an actor bucket and immediately
|
||||||
|
%% publish a Create{Person|Service|Group} as that actor's first
|
||||||
|
%% activity. Profile carries the object fields plus :public_keys.
|
||||||
|
%% Returns {ok, Result, NewState} where Result has the published
|
||||||
|
%% Create's CID, or {error, Reason, State} on validation halt.
|
||||||
|
|
||||||
|
bootstrap_actor(ActorId, Profile, KeySpec, State) ->
|
||||||
|
PublicKeys = case field(public_keys, Profile) of
|
||||||
|
nil -> [];
|
||||||
|
KS -> KS
|
||||||
|
end,
|
||||||
|
AS = [{public_keys, PublicKeys}],
|
||||||
|
case add_actor(ActorId, KeySpec, AS, State) of
|
||||||
|
{ok, State1} ->
|
||||||
|
ActorType = case field(type, Profile) of
|
||||||
|
nil -> person;
|
||||||
|
T -> T
|
||||||
|
end,
|
||||||
|
Object = [{type, ActorType}] ++ collect_profile_fields(
|
||||||
|
[name, preferredUsername, summary, icon, public_keys],
|
||||||
|
Profile),
|
||||||
|
Request = [{type, create}, {object, Object}],
|
||||||
|
publish(ActorId, Request, State1);
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason, State}
|
||||||
|
end.
|
||||||
|
|
||||||
|
collect_profile_fields([], _) -> [];
|
||||||
|
collect_profile_fields([F | Rest], Profile) ->
|
||||||
|
case field(F, Profile) of
|
||||||
|
nil -> collect_profile_fields(Rest, Profile);
|
||||||
|
V -> [{F, V} | collect_profile_fields(Rest, Profile)]
|
||||||
|
end.
|
||||||
|
|
||||||
|
with_actor_projections(ActorId, Names, State) ->
|
||||||
|
case actor_bucket(ActorId, State) of
|
||||||
|
{error, no_actor} ->
|
||||||
|
{error, no_actor};
|
||||||
|
{ok, Bucket} ->
|
||||||
|
B1 = set(projections, Names, Bucket),
|
||||||
|
{ok, set_bucket(ActorId, B1, State)}
|
||||||
|
end.
|
||||||
|
|
||||||
|
with_projections(Names, State) ->
|
||||||
|
case actors(State) of
|
||||||
|
[] -> State;
|
||||||
|
[First | _] ->
|
||||||
|
{ok, NewState} = with_actor_projections(First, Names, State),
|
||||||
|
NewState
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Per-actor accessors
|
||||||
|
|
||||||
|
actor_log_state(ActorId, State) ->
|
||||||
|
case actor_bucket(ActorId, State) of
|
||||||
|
{ok, B} -> {ok, field(log, B)};
|
||||||
|
{error, _} -> {error, no_actor}
|
||||||
|
end.
|
||||||
|
|
||||||
|
actor_log_tip(ActorId, State) ->
|
||||||
|
case actor_log_state(ActorId, State) of
|
||||||
|
{ok, L} -> log:tip(L);
|
||||||
|
{error, _} -> nil
|
||||||
|
end.
|
||||||
|
|
||||||
|
actor_inbox_state(ActorId, State) ->
|
||||||
|
case actor_bucket(ActorId, State) of
|
||||||
|
{ok, B} -> {ok, field(actor_inbox, B)};
|
||||||
|
{error, _} -> {error, no_actor}
|
||||||
|
end.
|
||||||
|
|
||||||
|
actor_inbox_tip(ActorId, State) ->
|
||||||
|
case actor_inbox_state(ActorId, State) of
|
||||||
|
{ok, I} -> log:tip(I);
|
||||||
|
{error, _} -> nil
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% append_to_actor_inbox/3 — pure-functional inbox append. Mirrors
|
||||||
|
%% publish/3's bucket-update shape; the activity is already signed
|
||||||
|
%% + validated by the time it lands here (Step 5's pipeline handles
|
||||||
|
%% sig verify + replay before this call).
|
||||||
|
|
||||||
|
append_to_actor_inbox(ActorId, Activity, State) ->
|
||||||
|
case actor_bucket(ActorId, State) of
|
||||||
|
{error, no_actor} ->
|
||||||
|
{error, no_actor, State};
|
||||||
|
{ok, Bucket} ->
|
||||||
|
Inbox = field(actor_inbox, Bucket),
|
||||||
|
{ok, NewInbox, _Seq} = log:append(Inbox, Activity),
|
||||||
|
B1 = set(actor_inbox, NewInbox, Bucket),
|
||||||
|
{ok, log:tip(NewInbox), set_bucket(ActorId, B1, State)}
|
||||||
|
end.
|
||||||
|
|
||||||
|
actor_key_spec(ActorId, State) ->
|
||||||
|
case actor_bucket(ActorId, State) of
|
||||||
|
{ok, B} -> {ok, field(key_spec, B)};
|
||||||
|
{error, _} -> {error, no_actor}
|
||||||
|
end.
|
||||||
|
|
||||||
|
actor_state(ActorId, State) when is_list(State), is_atom(ActorId) ->
|
||||||
|
case actor_bucket(ActorId, State) of
|
||||||
|
{ok, B} -> {ok, field(actor_state, B)};
|
||||||
|
{error, _} -> {error, no_actor}
|
||||||
|
end.
|
||||||
|
|
||||||
|
actor_projections(ActorId, State) ->
|
||||||
|
case actor_bucket(ActorId, State) of
|
||||||
|
{ok, B} -> {ok, field(projections, B)};
|
||||||
|
{error, _} -> {error, no_actor}
|
||||||
|
end.
|
||||||
|
|
||||||
|
actor_next_published(ActorId, State) ->
|
||||||
|
case actor_bucket(ActorId, State) of
|
||||||
|
{ok, B} -> {ok, field(next_published, B)};
|
||||||
|
{error, _} -> {error, no_actor}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% Legacy single-actor accessors — read from first bucket. Keeps
|
||||||
|
%% every M1 test (smoke_app_pure, bootstrap_start, http_publish,
|
||||||
|
%% nx_kernel_server, http_post_format) passing.
|
||||||
|
|
||||||
|
actor_id(State) ->
|
||||||
|
case field(actors, State) of
|
||||||
|
[] -> nil;
|
||||||
|
[{First, _Bucket} | _] -> First
|
||||||
|
end.
|
||||||
|
|
||||||
|
key_spec(State) ->
|
||||||
|
bucket_field(key_spec, State).
|
||||||
|
|
||||||
|
actor_state(State) ->
|
||||||
|
bucket_field(actor_state, State).
|
||||||
|
|
||||||
|
log_state(State) ->
|
||||||
|
bucket_field(log, State).
|
||||||
|
|
||||||
|
log_tip(State) ->
|
||||||
|
log:tip(log_state(State)).
|
||||||
|
|
||||||
|
projections(State) ->
|
||||||
|
case bucket_field(projections, State) of
|
||||||
|
nil -> [];
|
||||||
|
Ps -> Ps
|
||||||
|
end.
|
||||||
|
|
||||||
|
next_published(State) ->
|
||||||
|
bucket_field(next_published, State).
|
||||||
|
|
||||||
|
%% ── Internal helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
base_stub() ->
|
||||||
|
<<98,97,115,101,95,115,116,117,98>>.
|
||||||
|
|
||||||
|
%% "inbox_base_stub" — distinct path stub so the in-memory log
|
||||||
|
%% module's open/2 returns a fresh log state for the per-actor
|
||||||
|
%% inbox bucket. Disk paths will namespace on this once Step 3b
|
||||||
|
%% on-disk persistence is reactivated for inbox buckets.
|
||||||
|
inbox_base_stub() ->
|
||||||
|
<<105,110,98,111,120,95,115,116,117,98>>.
|
||||||
|
|
||||||
|
bucket_field(Key, State) ->
|
||||||
|
case field(actors, State) of
|
||||||
|
[] -> nil;
|
||||||
|
[{_First, Bucket} | _] -> field(Key, Bucket)
|
||||||
|
end.
|
||||||
|
|
||||||
|
set_bucket(ActorId, NewBucket, State) ->
|
||||||
|
Actors = field(actors, State),
|
||||||
|
NewActors = set_keyed(ActorId, NewBucket, Actors),
|
||||||
|
set(actors, NewActors, State).
|
||||||
|
|
||||||
|
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)];
|
||||||
|
set_keyed(_, _, []) -> [].
|
||||||
|
|
||||||
|
has_keyed(_, []) -> false;
|
||||||
|
has_keyed(K, [{K, _} | _]) -> true;
|
||||||
|
has_keyed(K, [_ | Rest]) -> has_keyed(K, Rest).
|
||||||
|
|
||||||
|
find_keyed(_, []) -> {error, no_actor};
|
||||||
|
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||||
|
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||||
|
|
||||||
|
field(K, [{K, V} | _]) -> V;
|
||||||
|
field(K, [_ | Rest]) -> field(K, Rest);
|
||||||
|
field(_, []) -> nil.
|
||||||
|
|
||||||
|
set(K, V, []) -> [{K, V}];
|
||||||
|
set(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set(K, V, [P | Rest]) -> [P | set(K, V, Rest)].
|
||||||
|
|
||||||
|
%% ── gen_server wrapper ──────────────────────────────────────────
|
||||||
|
%%
|
||||||
|
%% Mirrors the registry / projection gen_server patterns from
|
||||||
|
%% Steps 5b and 7b. Same port quirks: raw Pid return, no `?MODULE`
|
||||||
|
%% macro, spawned processes don't persist across separate
|
||||||
|
%% erlang-eval-ast calls — tests inline start_link with operations.
|
||||||
|
%%
|
||||||
|
%% Step 1b (m2) adds multi-actor gen_server calls:
|
||||||
|
%% add_actor/3, publish_to/2, log_tip_for/1, actors/0, state_for/1,
|
||||||
|
%% with_projections_for/2 — all delegating to the pure-functional
|
||||||
|
%% bucket APIs. Existing single-actor calls (publish/1, log_tip/0,
|
||||||
|
%% with_projections/1) continue to route through bucket 0.
|
||||||
|
|
||||||
|
start_link(ActorId, KeySpec, ActorStateProplist) ->
|
||||||
|
Pid = gen_server:start_link(nx_kernel,
|
||||||
|
[ActorId, KeySpec, ActorStateProplist]),
|
||||||
|
erlang:register(nx_kernel, Pid),
|
||||||
|
Pid.
|
||||||
|
|
||||||
|
stop() ->
|
||||||
|
R = gen_server:call(nx_kernel, '$gen_stop'),
|
||||||
|
erlang:unregister(nx_kernel),
|
||||||
|
R.
|
||||||
|
|
||||||
|
publish(Request) ->
|
||||||
|
gen_server:call(nx_kernel, {publish, Request}).
|
||||||
|
|
||||||
|
query() ->
|
||||||
|
gen_server:call(nx_kernel, get_state).
|
||||||
|
|
||||||
|
log_tip() ->
|
||||||
|
gen_server:call(nx_kernel, get_log_tip).
|
||||||
|
|
||||||
|
with_projections(Names) ->
|
||||||
|
gen_server:call(nx_kernel, {set_projections, Names}).
|
||||||
|
|
||||||
|
%% Step 1b — multi-actor gen_server calls.
|
||||||
|
|
||||||
|
add_actor(ActorId, KeySpec, AS) ->
|
||||||
|
gen_server:call(nx_kernel, {add_actor, ActorId, KeySpec, AS}).
|
||||||
|
|
||||||
|
publish_to(ActorId, Request) ->
|
||||||
|
gen_server:call(nx_kernel, {publish_to, ActorId, Request}).
|
||||||
|
|
||||||
|
log_tip_for(ActorId) ->
|
||||||
|
gen_server:call(nx_kernel, {log_tip_for, ActorId}).
|
||||||
|
|
||||||
|
log_state_for(ActorId) ->
|
||||||
|
gen_server:call(nx_kernel, {log_state_for, ActorId}).
|
||||||
|
|
||||||
|
inbox_tip_for(ActorId) ->
|
||||||
|
gen_server:call(nx_kernel, {inbox_tip_for, ActorId}).
|
||||||
|
|
||||||
|
inbox_state_for(ActorId) ->
|
||||||
|
gen_server:call(nx_kernel, {inbox_state_for, ActorId}).
|
||||||
|
|
||||||
|
append_inbox(ActorId, Activity) ->
|
||||||
|
gen_server:call(nx_kernel, {append_inbox, ActorId, Activity}).
|
||||||
|
|
||||||
|
actors() ->
|
||||||
|
gen_server:call(nx_kernel, get_actors).
|
||||||
|
|
||||||
|
state_for(ActorId) ->
|
||||||
|
gen_server:call(nx_kernel, {state_for, ActorId}).
|
||||||
|
|
||||||
|
bucket_for(ActorId) ->
|
||||||
|
gen_server:call(nx_kernel, {bucket_for, ActorId}).
|
||||||
|
|
||||||
|
with_projections_for(ActorId, Names) ->
|
||||||
|
gen_server:call(nx_kernel, {set_projections_for, ActorId, Names}).
|
||||||
|
|
||||||
|
bootstrap_actor(ActorId, Profile, KeySpec) ->
|
||||||
|
gen_server:call(nx_kernel, {bootstrap_actor, ActorId, Profile, KeySpec}).
|
||||||
|
|
||||||
|
%% gen_server callbacks
|
||||||
|
|
||||||
|
init([ActorId, KeySpec, AS]) ->
|
||||||
|
{ok, new(ActorId, KeySpec, AS)}.
|
||||||
|
|
||||||
|
handle_call({publish, Request}, _From, State) ->
|
||||||
|
case publish(Request, State) of
|
||||||
|
{ok, Result, NewState} ->
|
||||||
|
{reply, {ok, Result}, NewState};
|
||||||
|
{error, Reason, SameState} ->
|
||||||
|
{reply, {error, Reason}, SameState}
|
||||||
|
end;
|
||||||
|
handle_call(get_state, _From, State) ->
|
||||||
|
{reply, State, State};
|
||||||
|
handle_call(get_log_tip, _From, State) ->
|
||||||
|
{reply, log_tip(State), State};
|
||||||
|
handle_call({set_projections, Names}, _From, State) ->
|
||||||
|
{reply, ok, with_projections(Names, State)};
|
||||||
|
handle_call({add_actor, ActorId, KeySpec, AS}, _From, State) ->
|
||||||
|
case add_actor(ActorId, KeySpec, AS, State) of
|
||||||
|
{ok, NewState} -> {reply, ok, NewState};
|
||||||
|
{error, Reason} -> {reply, {error, Reason}, State}
|
||||||
|
end;
|
||||||
|
handle_call({publish_to, ActorId, Request}, _From, State) ->
|
||||||
|
case publish(ActorId, Request, State) of
|
||||||
|
{ok, Result, NewState} -> {reply, {ok, Result}, NewState};
|
||||||
|
{error, Reason, SameState} -> {reply, {error, Reason}, SameState}
|
||||||
|
end;
|
||||||
|
handle_call({log_tip_for, ActorId}, _From, State) ->
|
||||||
|
{reply, actor_log_tip(ActorId, State), State};
|
||||||
|
handle_call({log_state_for, ActorId}, _From, State) ->
|
||||||
|
{reply, actor_log_state(ActorId, State), State};
|
||||||
|
handle_call({inbox_tip_for, ActorId}, _From, State) ->
|
||||||
|
{reply, actor_inbox_tip(ActorId, State), State};
|
||||||
|
handle_call({inbox_state_for, ActorId}, _From, State) ->
|
||||||
|
{reply, actor_inbox_state(ActorId, State), State};
|
||||||
|
handle_call({append_inbox, ActorId, Activity}, _From, State) ->
|
||||||
|
case append_to_actor_inbox(ActorId, Activity, State) of
|
||||||
|
{ok, Tip, NewState} -> {reply, {ok, Tip}, NewState};
|
||||||
|
{error, Reason, Same} -> {reply, {error, Reason}, Same}
|
||||||
|
end;
|
||||||
|
handle_call(get_actors, _From, State) ->
|
||||||
|
{reply, actors(State), State};
|
||||||
|
handle_call({state_for, ActorId}, _From, State) ->
|
||||||
|
{reply, actor_state(ActorId, State), State};
|
||||||
|
handle_call({bucket_for, ActorId}, _From, State) ->
|
||||||
|
{reply, actor_bucket(ActorId, State), State};
|
||||||
|
handle_call({set_projections_for, ActorId, Names}, _From, State) ->
|
||||||
|
case with_actor_projections(ActorId, Names, State) of
|
||||||
|
{ok, NewState} -> {reply, ok, NewState};
|
||||||
|
{error, Reason} -> {reply, {error, Reason}, State}
|
||||||
|
end;
|
||||||
|
handle_call({bootstrap_actor, ActorId, Profile, KeySpec}, _From, State) ->
|
||||||
|
case bootstrap_actor(ActorId, Profile, KeySpec, State) of
|
||||||
|
{ok, Result, NewState} -> {reply, {ok, Result}, NewState};
|
||||||
|
{error, Reason, SameState} -> {reply, {error, Reason}, SameState}
|
||||||
|
end.
|
||||||
|
|
||||||
|
handle_cast(_, S) -> {noreply, S}.
|
||||||
|
|
||||||
|
handle_info(_, S) -> {noreply, S}.
|
||||||
188
next/kernel/outbox.erl
Normal file
188
next/kernel/outbox.erl
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
-module(outbox).
|
||||||
|
-export([construct/4, sign/2, cid_of/1, publish/2]).
|
||||||
|
|
||||||
|
%% Outbox envelope construction + signing per design §3.1.
|
||||||
|
%%
|
||||||
|
%% construct/4 builds an unsigned activity envelope from caller-supplied
|
||||||
|
%% (Type, ActorId, Published, Object). The envelope's `:id` field is
|
||||||
|
%% derived from the host `cid:to_string` BIF over a skeleton tag, so
|
||||||
|
%% recipients can address the activity by its content hash. The
|
||||||
|
%% returned property list is the canonical key-sorted form that
|
||||||
|
%% `envelope:canonical_bytes/1` operates on.
|
||||||
|
%%
|
||||||
|
%% sign/2 takes the unsigned envelope plus a KeySpec proplist that
|
||||||
|
%% mirrors a `public_keys` entry: `[{key_id, _}, {algorithm, _},
|
||||||
|
%% {value, KeyMaterial}]`. It computes the v1 HMAC stand-in
|
||||||
|
%% `crypto:hash(sha256, <<KeyMaterial/binary, CanonicalBytes/binary>>)`
|
||||||
|
%% — the same scheme `envelope:verify_signature/2` checks — and
|
||||||
|
%% appends a `:signature` pair.
|
||||||
|
%%
|
||||||
|
%% Real Ed25519 / RSA signing arrives in milestone 2 once
|
||||||
|
%% `crypto:sign_ed25519/2` BIFs land; the API shape doesn't change.
|
||||||
|
|
||||||
|
%% construct/4 — Type and ActorId are atoms; Published is an
|
||||||
|
%% integer timestamp the caller supplies (no clock BIF in this
|
||||||
|
%% port; the HTTP layer / outbox:publish caller injects it).
|
||||||
|
%% Object can be any term, including a property list of inner
|
||||||
|
%% fields.
|
||||||
|
construct(Type, ActorId, Published, Object) ->
|
||||||
|
Skeleton = [{actor, ActorId},
|
||||||
|
{object, Object},
|
||||||
|
{published, Published},
|
||||||
|
{type, Type}],
|
||||||
|
Id = cid:to_string({activity_envelope, Skeleton}),
|
||||||
|
[{actor, ActorId},
|
||||||
|
{id, Id},
|
||||||
|
{object, Object},
|
||||||
|
{published, Published},
|
||||||
|
{type, Type}].
|
||||||
|
|
||||||
|
%% sign/2 — KeySpec carries key_id, algorithm, value (key material).
|
||||||
|
sign(Envelope, KeySpec) ->
|
||||||
|
{ok, KeyId} = envelope:get_field(key_id, KeySpec),
|
||||||
|
{ok, Alg} = envelope:get_field(algorithm, KeySpec),
|
||||||
|
{ok, KM} = envelope:get_field(value, KeySpec),
|
||||||
|
CB = envelope:canonical_bytes(Envelope),
|
||||||
|
SigValue = crypto:hash(sha256, <<KM/binary, CB/binary>>),
|
||||||
|
Sig = [{algorithm, Alg}, {key_id, KeyId}, {value, SigValue}],
|
||||||
|
Envelope ++ [{signature, Sig}].
|
||||||
|
|
||||||
|
%% cid_of/1 — extract the :id field from a constructed envelope.
|
||||||
|
%% Convenience for callers that don't want to thread the CID
|
||||||
|
%% separately when both the envelope and its ID matter.
|
||||||
|
cid_of(Envelope) ->
|
||||||
|
{ok, Id} = envelope:get_field(id, Envelope),
|
||||||
|
Id.
|
||||||
|
|
||||||
|
%% publish/2 — the outbound activity pipeline orchestrator.
|
||||||
|
%%
|
||||||
|
%% Request shape: [{type, T}, {object, O}]
|
||||||
|
%% Context shape: [{actor_id, A}, {published, P}, {key_spec, KS},
|
||||||
|
%% {actor_state, AS}, {log, L}]
|
||||||
|
%%
|
||||||
|
%% Returns:
|
||||||
|
%% {ok, [{cid, Cid}, {activity, Signed}], NewLog} — happy path
|
||||||
|
%% {error, Reason, LogState} — validation halted
|
||||||
|
%%
|
||||||
|
%% Stages run in order: envelope shape, signature, replay. The
|
||||||
|
%% replay check uses the log state pre-append, so if the caller
|
||||||
|
%% publishes the same Request twice with the same Published
|
||||||
|
%% timestamp the second call halts with {error, replay, _}.
|
||||||
|
%%
|
||||||
|
%% Projection-scheduler dispatch (the async fold the design calls
|
||||||
|
%% for) is deferred to Step 7 — once the projection gen_server
|
||||||
|
%% exists, this function will broadcast `Signed` to it.
|
||||||
|
|
||||||
|
publish(Request, Context) ->
|
||||||
|
Type = envelope_field(type, Request),
|
||||||
|
Object = envelope_field(object, Request),
|
||||||
|
ActorId = envelope_field(actor_id, Context),
|
||||||
|
Published = envelope_field(published, Context),
|
||||||
|
KeySpec = envelope_field(key_spec, Context),
|
||||||
|
ActorState = envelope_field(actor_state, Context),
|
||||||
|
LogState = envelope_field(log, Context),
|
||||||
|
Unsigned = construct(Type, ActorId, Published, Object),
|
||||||
|
Signed = sign(Unsigned, KeySpec),
|
||||||
|
Stages = [
|
||||||
|
fun (A) -> pipeline:stage_envelope(A) end,
|
||||||
|
pipeline:stage_signature(ActorState),
|
||||||
|
pipeline:stage_replay(LogState)
|
||||||
|
],
|
||||||
|
case pipeline:run_stages(Signed, Stages) of
|
||||||
|
ok ->
|
||||||
|
{ok, NewLog, _Seq} = log:append(LogState, Signed),
|
||||||
|
broadcast(Signed, envelope_field(projections, Context)),
|
||||||
|
DeliverySet = compute_delivery_set(Request, Signed, Context),
|
||||||
|
dispatch_deliveries(Signed, DeliverySet, Context),
|
||||||
|
Result = [{cid, cid_of(Signed)},
|
||||||
|
{activity, Signed},
|
||||||
|
{delivery_set, DeliverySet}],
|
||||||
|
{ok, Result, NewLog};
|
||||||
|
{error, Reason} ->
|
||||||
|
{error, Reason, LogState}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% dispatch_deliveries/3 — Step 8d. For each ActorId in the
|
||||||
|
%% delivery_set, enqueue the signed activity onto the matching
|
||||||
|
%% delivery_worker if the worker is registered under that atom.
|
||||||
|
%% Missing workers are silently skipped — lazy creation belongs
|
||||||
|
%% to the kernel manager (later in Step 8). The Context
|
||||||
|
%% `:dispatch_deliveries` field gates the call so existing
|
||||||
|
%% outbox callers that don't yet care about delivery (e.g. all of
|
||||||
|
%% M1's tests) stay back-compat.
|
||||||
|
%%
|
||||||
|
%% No-op when:
|
||||||
|
%% - :dispatch_deliveries is absent or not the atom true
|
||||||
|
%% - delivery_set is []
|
||||||
|
%% - the per-peer worker isn't registered (whereis returns undefined)
|
||||||
|
|
||||||
|
dispatch_deliveries(Activity, DeliverySet, Context) ->
|
||||||
|
case envelope_field(dispatch_deliveries, Context) of
|
||||||
|
true -> enqueue_each(Activity, DeliverySet);
|
||||||
|
_ -> ok
|
||||||
|
end.
|
||||||
|
|
||||||
|
enqueue_each(_Activity, []) -> ok;
|
||||||
|
enqueue_each(Activity, [PeerId | Rest]) when is_atom(PeerId) ->
|
||||||
|
case erlang:whereis(PeerId) of
|
||||||
|
undefined -> enqueue_each(Activity, Rest);
|
||||||
|
_ ->
|
||||||
|
delivery_worker:enqueue(PeerId, Activity),
|
||||||
|
enqueue_each(Activity, Rest)
|
||||||
|
end;
|
||||||
|
enqueue_each(Activity, [_ | Rest]) ->
|
||||||
|
enqueue_each(Activity, Rest).
|
||||||
|
|
||||||
|
%% compute_delivery_set/3 — Step 7c. Pulls the audience-resolved
|
||||||
|
%% recipient list off the Request's `:to` / `:cc` fields (the
|
||||||
|
%% envelope itself doesn't carry them — construct/4 only takes
|
||||||
|
%% type / actor / published / object). Context's optional
|
||||||
|
%% `:follower_graph` field carries a follower_graph state for
|
||||||
|
%% `public` / `followers` audience expansion; absent -> empty graph,
|
||||||
|
%% so explicit `:to` / `:cc` lists still resolve. Synthesises a
|
||||||
|
%% recipient-shaped envelope from Request + Signed so the existing
|
||||||
|
%% delivery:delivery_set/3 (which reads `:actor`, `:to`, `:cc`) can
|
||||||
|
%% process it as-is.
|
||||||
|
%%
|
||||||
|
%% Step 8's delivery-queue worker reads `{delivery_set, [ActorId, ...]}`
|
||||||
|
%% off the publish result and routes one HTTP POST per entry.
|
||||||
|
|
||||||
|
compute_delivery_set(Request, Signed, Context) ->
|
||||||
|
Graph = case envelope_field(follower_graph, Context) of
|
||||||
|
nil -> follower_graph:new();
|
||||||
|
G -> G
|
||||||
|
end,
|
||||||
|
Recipients = recipients_envelope(Request, Signed),
|
||||||
|
delivery:delivery_set(Recipients, [], Graph).
|
||||||
|
|
||||||
|
recipients_envelope(Request, Signed) ->
|
||||||
|
Base = case envelope:get_field(actor, Signed) of
|
||||||
|
{ok, A} -> [{actor, A}];
|
||||||
|
_ -> []
|
||||||
|
end,
|
||||||
|
To = case envelope:get_field(to, Request) of
|
||||||
|
{ok, T} -> [{to, T}];
|
||||||
|
_ -> []
|
||||||
|
end,
|
||||||
|
Cc = case envelope:get_field(cc, Request) of
|
||||||
|
{ok, C} -> [{cc, C}];
|
||||||
|
_ -> []
|
||||||
|
end,
|
||||||
|
Base ++ To ++ Cc.
|
||||||
|
|
||||||
|
%% broadcast/2 — fire-and-forget cast to each named projection.
|
||||||
|
%% Missing/nil/empty list is a no-op; the publish API does not
|
||||||
|
%% require projections to exist. Activity is the post-sign Signed
|
||||||
|
%% envelope (same value that landed in the log).
|
||||||
|
broadcast(_Activity, nil) -> ok;
|
||||||
|
broadcast(_Activity, []) -> ok;
|
||||||
|
broadcast(Activity, [Name | Rest]) ->
|
||||||
|
projection:async_fold(Name, Activity),
|
||||||
|
broadcast(Activity, Rest).
|
||||||
|
|
||||||
|
envelope_field(K, PL) ->
|
||||||
|
case envelope:get_field(K, PL) of
|
||||||
|
{ok, V} -> V;
|
||||||
|
not_found -> nil
|
||||||
|
end.
|
||||||
|
|
||||||
140
next/kernel/peer_actors.erl
Normal file
140
next/kernel/peer_actors.erl
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
-module(peer_actors).
|
||||||
|
-export([new/0, lookup/2, store/3, evict/2, peers/1,
|
||||||
|
lookup_or_fetch/3,
|
||||||
|
start_link/0, start_link/1, stop/0,
|
||||||
|
lookup_srv/1, store_srv/2, lookup_or_fetch_srv/2,
|
||||||
|
peers_srv/0, evict_srv/1]).
|
||||||
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||||
|
-behaviour(gen_server).
|
||||||
|
|
||||||
|
%% Peer-actors cache. On first inbound from a new peer, the
|
||||||
|
%% federation layer needs the peer's `:public_keys` (and eventually
|
||||||
|
%% other actor-doc fields) to verify the inbound signature. Fetching
|
||||||
|
%% the peer's actor doc on every inbound would be wasteful, so we
|
||||||
|
%% cache the peer-AS keyed by ActorId atom. Per design §13.6 stale-
|
||||||
|
%% key invalidation defers to v3 — for v2 entries are TTL-free.
|
||||||
|
%%
|
||||||
|
%% State shape (pure-functional):
|
||||||
|
%% [{PeerActorId, PeerActorState}, ...]
|
||||||
|
%%
|
||||||
|
%% PeerActorState is the same shape that envelope:verify_signature/2
|
||||||
|
%% reads — a proplist with :public_keys (a list of key proplists).
|
||||||
|
%%
|
||||||
|
%% lookup_or_fetch/3 is the load-bearing entry point: a miss invokes
|
||||||
|
%% the caller-supplied FetchFn (1-arity, takes PeerActorId, returns
|
||||||
|
%% {ok, PeerAS} | {error, Reason}). The cache stores successful
|
||||||
|
%% fetches; errors do NOT poison the cache so the caller can retry.
|
||||||
|
%%
|
||||||
|
%% gen_server wrapper exposes the same API for the http inbox
|
||||||
|
%% handler. Tests inline start_link with operations (same port quirks
|
||||||
|
%% as registry / projection / nx_kernel).
|
||||||
|
|
||||||
|
%% ── Pure-functional API ─────────────────────────────────────────
|
||||||
|
|
||||||
|
new() -> [].
|
||||||
|
|
||||||
|
lookup(PeerId, State) ->
|
||||||
|
case find_keyed(PeerId, State) of
|
||||||
|
{ok, PeerAS} -> {ok, PeerAS};
|
||||||
|
{error, _} -> not_found
|
||||||
|
end.
|
||||||
|
|
||||||
|
store(PeerId, PeerAS, State) ->
|
||||||
|
set_keyed(PeerId, PeerAS, State).
|
||||||
|
|
||||||
|
evict(PeerId, State) ->
|
||||||
|
delete_keyed(PeerId, State).
|
||||||
|
|
||||||
|
peers(State) -> [Id || {Id, _AS} <- State].
|
||||||
|
|
||||||
|
%% lookup_or_fetch/3 — cache hit returns {ok, PeerAS, State}
|
||||||
|
%% unchanged. Cache miss calls FetchFn; success path stores and
|
||||||
|
%% returns {ok, PeerAS, NewState}; failure returns {error, Reason,
|
||||||
|
%% State} so the caller knows the cache state and can retry on
|
||||||
|
%% transient errors.
|
||||||
|
|
||||||
|
lookup_or_fetch(PeerId, FetchFn, State) ->
|
||||||
|
case find_keyed(PeerId, State) of
|
||||||
|
{ok, PeerAS} -> {ok, PeerAS, State};
|
||||||
|
{error, _} ->
|
||||||
|
case FetchFn(PeerId) of
|
||||||
|
{ok, PeerAS} -> {ok, PeerAS, store(PeerId, PeerAS, State)};
|
||||||
|
{error, Reason} -> {error, Reason, State};
|
||||||
|
Other -> {error, {bad_fetch_return, Other}, State}
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── gen_server wrapper ──────────────────────────────────────────
|
||||||
|
%%
|
||||||
|
%% Mirrors registry / projection / nx_kernel patterns. Registered
|
||||||
|
%% name `peer_actors` so callers (http_server inbox handler) can
|
||||||
|
%% find it without threading the Pid through Cfg.
|
||||||
|
|
||||||
|
start_link() ->
|
||||||
|
start_link([]).
|
||||||
|
|
||||||
|
start_link(InitialState) ->
|
||||||
|
Pid = gen_server:start_link(peer_actors, [InitialState]),
|
||||||
|
erlang:register(peer_actors, Pid),
|
||||||
|
Pid.
|
||||||
|
|
||||||
|
stop() ->
|
||||||
|
R = gen_server:call(peer_actors, '$gen_stop'),
|
||||||
|
erlang:unregister(peer_actors),
|
||||||
|
R.
|
||||||
|
|
||||||
|
lookup_srv(PeerId) ->
|
||||||
|
gen_server:call(peer_actors, {lookup, PeerId}).
|
||||||
|
|
||||||
|
store_srv(PeerId, PeerAS) ->
|
||||||
|
gen_server:call(peer_actors, {store, PeerId, PeerAS}).
|
||||||
|
|
||||||
|
%% lookup_or_fetch_srv/2 — same shape as the pure form. FetchFn must
|
||||||
|
%% be a 1-arity fun. Reply is {ok, PeerAS} on hit-or-fetched,
|
||||||
|
%% {error, Reason} on fetch failure.
|
||||||
|
|
||||||
|
lookup_or_fetch_srv(PeerId, FetchFn) ->
|
||||||
|
gen_server:call(peer_actors, {lookup_or_fetch, PeerId, FetchFn}).
|
||||||
|
|
||||||
|
peers_srv() ->
|
||||||
|
gen_server:call(peer_actors, get_peers).
|
||||||
|
|
||||||
|
evict_srv(PeerId) ->
|
||||||
|
gen_server:call(peer_actors, {evict, PeerId}).
|
||||||
|
|
||||||
|
%% gen_server callbacks
|
||||||
|
|
||||||
|
init([InitialState]) ->
|
||||||
|
{ok, InitialState}.
|
||||||
|
|
||||||
|
handle_call({lookup, PeerId}, _From, State) ->
|
||||||
|
{reply, lookup(PeerId, State), State};
|
||||||
|
handle_call({store, PeerId, PeerAS}, _From, State) ->
|
||||||
|
{reply, ok, store(PeerId, PeerAS, State)};
|
||||||
|
handle_call({lookup_or_fetch, PeerId, FetchFn}, _From, State) ->
|
||||||
|
case lookup_or_fetch(PeerId, FetchFn, State) of
|
||||||
|
{ok, PeerAS, NewState} -> {reply, {ok, PeerAS}, NewState};
|
||||||
|
{error, Reason, SameState} -> {reply, {error, Reason}, SameState}
|
||||||
|
end;
|
||||||
|
handle_call(get_peers, _From, State) ->
|
||||||
|
{reply, peers(State), State};
|
||||||
|
handle_call({evict, PeerId}, _From, State) ->
|
||||||
|
{reply, ok, evict(PeerId, State)}.
|
||||||
|
|
||||||
|
handle_cast(_, S) -> {noreply, S}.
|
||||||
|
|
||||||
|
handle_info(_, S) -> {noreply, S}.
|
||||||
|
|
||||||
|
%% ── Internal helpers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
find_keyed(_, []) -> {error, not_found};
|
||||||
|
find_keyed(K, [{K, V} | _]) -> {ok, V};
|
||||||
|
find_keyed(K, [_ | Rest]) -> find_keyed(K, Rest).
|
||||||
|
|
||||||
|
set_keyed(K, V, []) -> [{K, V}];
|
||||||
|
set_keyed(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set_keyed(K, V, [P | Rest]) -> [P | set_keyed(K, V, Rest)].
|
||||||
|
|
||||||
|
delete_keyed(_, []) -> [];
|
||||||
|
delete_keyed(K, [{K, _} | Rest]) -> Rest;
|
||||||
|
delete_keyed(K, [P | Rest]) -> [P | delete_keyed(K, Rest)].
|
||||||
167
next/kernel/pipeline.erl
Normal file
167
next/kernel/pipeline.erl
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
-module(pipeline).
|
||||||
|
-export([run_stages/2,
|
||||||
|
validate_inbound/1, validate_inbound/3,
|
||||||
|
validate_outbound/1,
|
||||||
|
inbound_stages/0, inbound_stages/2, outbound_stages/0,
|
||||||
|
stage_envelope/1,
|
||||||
|
stage_signature/1, stage_signature/2,
|
||||||
|
stage_replay/1, stage_replay/2,
|
||||||
|
stage_schema/1, stage_schema/2]).
|
||||||
|
|
||||||
|
%% Validation pipeline per design §14.
|
||||||
|
%%
|
||||||
|
%% A stage is a 1-arity fun `(Activity) -> ok | {error, Reason}`.
|
||||||
|
%% The driver folds the activity through the stage list, halting
|
||||||
|
%% on the first error. The pure-functional driver itself takes a
|
||||||
|
%% stage list directly so tests can inject ad-hoc stage sequences
|
||||||
|
%% without depending on the bundled inbound/outbound lists.
|
||||||
|
%%
|
||||||
|
%% Inbound pipeline (full set per design §14): envelope, signature,
|
||||||
|
%% replay, audience, activity_schema, object_schema, content_validators,
|
||||||
|
%% capabilities, trust. Outbound is a subset (no replay, no trust;
|
||||||
|
%% auth handled at the HTTP layer).
|
||||||
|
%%
|
||||||
|
%% This sub-deliverable (6a) wires only the driver and the empty
|
||||||
|
%% stage lists. Concrete stages land in 6b-6c.
|
||||||
|
|
||||||
|
run_stages(_Activity, []) -> ok;
|
||||||
|
run_stages(Activity, [Stage | Rest]) ->
|
||||||
|
Result = Stage(Activity),
|
||||||
|
case Result of
|
||||||
|
ok -> run_stages(Activity, Rest);
|
||||||
|
{error, _} -> Result
|
||||||
|
end.
|
||||||
|
|
||||||
|
validate_inbound(Activity) ->
|
||||||
|
run_stages(Activity, inbound_stages()).
|
||||||
|
|
||||||
|
%% validate_inbound/3 — Step 5b federation inbound pipeline.
|
||||||
|
%%
|
||||||
|
%% Activity: the signed envelope as received from the peer.
|
||||||
|
%% PeerActorState: the peer's actor-state proplist carrying
|
||||||
|
%% :public_keys for signature verification. Caller
|
||||||
|
%% resolves this — for v2 it's either pre-populated
|
||||||
|
%% from a peer-actors cache (Step 5c) or known from
|
||||||
|
%% a two-instance test fixture.
|
||||||
|
%% InboxLog: the receiving actor's :actor_inbox log state.
|
||||||
|
%% Used by stage_replay to reject duplicate :id.
|
||||||
|
%%
|
||||||
|
%% Stages (per design §13.2 + §14):
|
||||||
|
%% stage_envelope — shape check
|
||||||
|
%% stage_signature(PeerAS) — peer sig verify
|
||||||
|
%% stage_replay(InboxLog) — replay defence against
|
||||||
|
%% receiving actor's inbox
|
||||||
|
%%
|
||||||
|
%% Returns ok | {error, Reason}. The driver halts on first failure.
|
||||||
|
%% Audience / schema / capabilities / trust stages defer to v3.
|
||||||
|
|
||||||
|
validate_inbound(Activity, PeerActorState, InboxLog) ->
|
||||||
|
run_stages(Activity, inbound_stages(PeerActorState, InboxLog)).
|
||||||
|
|
||||||
|
validate_outbound(Activity) ->
|
||||||
|
run_stages(Activity, outbound_stages()).
|
||||||
|
|
||||||
|
inbound_stages() ->
|
||||||
|
[fun (A) -> stage_envelope(A) end].
|
||||||
|
|
||||||
|
%% inbound_stages/2 — the full ordered stage list for federation
|
||||||
|
%% inbound (envelope -> peer sig -> replay against inbox).
|
||||||
|
|
||||||
|
inbound_stages(PeerActorState, InboxLog) ->
|
||||||
|
[fun (A) -> stage_envelope(A) end,
|
||||||
|
stage_signature(PeerActorState),
|
||||||
|
stage_replay(InboxLog)].
|
||||||
|
|
||||||
|
outbound_stages() ->
|
||||||
|
[fun (A) -> stage_envelope(A) end].
|
||||||
|
|
||||||
|
%% ── Concrete stages ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
%% stage_envelope/1 — wrap envelope:validate_shape/1. The pipeline
|
||||||
|
%% driver expects ok | {error, R}; validate_shape returns exactly
|
||||||
|
%% that, so delegation is direct.
|
||||||
|
stage_envelope(Activity) ->
|
||||||
|
envelope:validate_shape(Activity).
|
||||||
|
|
||||||
|
%% stage_signature/2 — direct (Activity, ActorState) check. Wraps
|
||||||
|
%% envelope:verify_signature/2 from Step 2c. Useful for tests and
|
||||||
|
%% for callers that already have ActorState in scope.
|
||||||
|
stage_signature(Activity, ActorState) ->
|
||||||
|
envelope:verify_signature(Activity, ActorState).
|
||||||
|
|
||||||
|
%% stage_signature/1 — factory: takes the ActorState and returns a
|
||||||
|
%% 1-arity stage fun the pipeline driver can fold. This is how
|
||||||
|
%% signature checking gets composed into a stage list at runtime
|
||||||
|
%% (the static `inbound_stages/0` list omits it precisely because
|
||||||
|
%% ActorState isn't available at static-list build time).
|
||||||
|
stage_signature(ActorState) ->
|
||||||
|
fun (Activity) -> envelope:verify_signature(Activity, ActorState) end.
|
||||||
|
|
||||||
|
%% stage_replay/2 — checks the in-memory log for an existing
|
||||||
|
%% activity with the same :id. Returns ok if the activity is new,
|
||||||
|
%% `{error, replay}` if the log already carries it, `{error, no_id}`
|
||||||
|
%% if the activity has no :id field. The check is linear scan of
|
||||||
|
%% log entries; the projection scheduler (Step 7) will eventually
|
||||||
|
%% maintain a CID index that turns this into O(1).
|
||||||
|
stage_replay(Activity, LogState) ->
|
||||||
|
case envelope:get_field(id, Activity) of
|
||||||
|
not_found -> {error, no_id};
|
||||||
|
{ok, Id} ->
|
||||||
|
case log_has_id(Id, log:entries(LogState)) of
|
||||||
|
true -> {error, replay};
|
||||||
|
false -> ok
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
stage_replay(LogState) ->
|
||||||
|
fun (Activity) -> stage_replay(Activity, LogState) end.
|
||||||
|
|
||||||
|
log_has_id(_, []) -> false;
|
||||||
|
log_has_id(Id, [Act | Rest]) ->
|
||||||
|
case envelope:get_field(id, Act) of
|
||||||
|
{ok, Id} -> true;
|
||||||
|
_ -> log_has_id(Id, Rest)
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% stage_schema/2 — validates the activity's :object against the
|
||||||
|
%% schema registered for its :type. SchemaLookup is a caller-
|
||||||
|
%% supplied fun (Type) -> {ok, SchemaFn} | not_found; SchemaFn is
|
||||||
|
%% itself a fun (Object) -> bool. Returns:
|
||||||
|
%% ok when the schema accepts the object
|
||||||
|
%% {error, no_type} when the activity has no :type
|
||||||
|
%% {error, schema_mismatch} when SchemaFn returned false
|
||||||
|
%%
|
||||||
|
%% Open-world default: an unregistered Type returns ok so the
|
||||||
|
%% pipeline doesn't block activities the kernel hasn't yet learned
|
||||||
|
%% about. Tightening to strict-world happens later in milestone 2.
|
||||||
|
%%
|
||||||
|
%% Activities with no :object skip the schema check (some verbs
|
||||||
|
%% legitimately carry no object).
|
||||||
|
%%
|
||||||
|
%% The Erlang-fun shape is the substrate-friendly stand-in for the
|
||||||
|
%% SX-source :schema bodies stored in the genesis bundle. Once an
|
||||||
|
%% SX-source eval bridge exists, the same stage shape will dispatch
|
||||||
|
%% through it instead — no API change.
|
||||||
|
stage_schema(Activity, SchemaLookup) ->
|
||||||
|
case envelope:get_field(type, Activity) of
|
||||||
|
not_found -> {error, no_type};
|
||||||
|
{ok, Type} ->
|
||||||
|
case SchemaLookup(Type) of
|
||||||
|
not_found -> ok;
|
||||||
|
{ok, SchemaFn} ->
|
||||||
|
check_object_schema(Activity, SchemaFn)
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
check_object_schema(Activity, SchemaFn) ->
|
||||||
|
case envelope:get_field(object, Activity) of
|
||||||
|
not_found -> ok;
|
||||||
|
{ok, Obj} ->
|
||||||
|
case SchemaFn(Obj) of
|
||||||
|
true -> ok;
|
||||||
|
false -> {error, schema_mismatch}
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
stage_schema(SchemaLookup) ->
|
||||||
|
fun (Activity) -> stage_schema(Activity, SchemaLookup) end.
|
||||||
97
next/kernel/projection.erl
Normal file
97
next/kernel/projection.erl
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
-module(projection).
|
||||||
|
-behaviour(gen_server).
|
||||||
|
-export([new/2, new/3, fold_activity/2, replay/2,
|
||||||
|
name/1, state/1, fold_fn/1]).
|
||||||
|
-export([start_link/3, async_fold/2, query/1, stop/1]).
|
||||||
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||||
|
|
||||||
|
%% Pure-functional projection driver per design §10.
|
||||||
|
%%
|
||||||
|
%% A projection is a property list:
|
||||||
|
%% [{name, atom}, {state, term}, {fold, fun}]
|
||||||
|
%%
|
||||||
|
%% The fold function is `fun (Activity, State) -> NewState`. v1
|
||||||
|
%% uses Erlang funs as the fold body — the genesis bundle's SX
|
||||||
|
%% `:fold` bodies are stored as binaries; an SX-source eval
|
||||||
|
%% bridge will plug them into the same projection record once
|
||||||
|
%% it lands (Step 7d). For now, callers supply Erlang funs
|
||||||
|
%% directly when constructing a projection.
|
||||||
|
%%
|
||||||
|
%% `replay/2` is the cold-start primitive: fold an activity
|
||||||
|
%% list (e.g. `log:entries/1`) through the projection from its
|
||||||
|
%% initial state.
|
||||||
|
|
||||||
|
new(Name, InitialState) ->
|
||||||
|
new(Name, InitialState, fun (_Activity, S) -> S end).
|
||||||
|
|
||||||
|
new(Name, InitialState, FoldFn) ->
|
||||||
|
[{name, Name}, {state, InitialState}, {fold, FoldFn}].
|
||||||
|
|
||||||
|
fold_activity(Proj, Activity) ->
|
||||||
|
Fn = fold_fn(Proj),
|
||||||
|
S0 = state(Proj),
|
||||||
|
S1 = Fn(Activity, S0),
|
||||||
|
set_field(state, S1, Proj).
|
||||||
|
|
||||||
|
replay(Proj, Activities) ->
|
||||||
|
fold_each(Proj, Activities).
|
||||||
|
|
||||||
|
fold_each(Proj, []) -> Proj;
|
||||||
|
fold_each(Proj, [A | Rest]) ->
|
||||||
|
fold_each(fold_activity(Proj, A), Rest).
|
||||||
|
|
||||||
|
%% Accessors
|
||||||
|
|
||||||
|
name(Proj) -> field(name, Proj).
|
||||||
|
state(Proj) -> field(state, Proj).
|
||||||
|
fold_fn(Proj) -> field(fold, Proj).
|
||||||
|
|
||||||
|
%% Internal
|
||||||
|
|
||||||
|
field(K, [{K, V} | _]) -> V;
|
||||||
|
field(K, [_ | Rest]) -> field(K, Rest);
|
||||||
|
field(_, []) -> erlang:error(badkey).
|
||||||
|
|
||||||
|
set_field(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
set_field(K, V, [P | Rest]) -> [P | set_field(K, V, Rest)];
|
||||||
|
set_field(K, V, []) -> [{K, V}].
|
||||||
|
|
||||||
|
%% ── Step 7b: gen_server wrapper ─────────────────────────────────
|
||||||
|
%%
|
||||||
|
%% Each projection runs in its own gen_server, registered under the
|
||||||
|
%% projection's Name atom. `async_fold/2` casts an activity into the
|
||||||
|
%% process; `query/1` synchronously fetches the current state.
|
||||||
|
%%
|
||||||
|
%% Port notes (mirroring Step 5b on the registry): `gen_server:start_link`
|
||||||
|
%% returns the raw Pid; `?MODULE` macro is unsupported; spawned
|
||||||
|
%% processes don't survive across separate `erlang-eval-ast` calls
|
||||||
|
%% so tests must inline start_link with their operations.
|
||||||
|
|
||||||
|
start_link(Name, InitialState, FoldFn) ->
|
||||||
|
Pid = gen_server:start_link(projection, [Name, InitialState, FoldFn]),
|
||||||
|
erlang:register(Name, Pid),
|
||||||
|
Pid.
|
||||||
|
|
||||||
|
async_fold(Name, Activity) ->
|
||||||
|
gen_server:cast(Name, {fold, Activity}).
|
||||||
|
|
||||||
|
query(Name) ->
|
||||||
|
gen_server:call(Name, get_state).
|
||||||
|
|
||||||
|
stop(Name) ->
|
||||||
|
R = gen_server:call(Name, '$gen_stop'),
|
||||||
|
erlang:unregister(Name),
|
||||||
|
R.
|
||||||
|
|
||||||
|
%% gen_server callbacks
|
||||||
|
|
||||||
|
init([Name, InitialState, FoldFn]) ->
|
||||||
|
{ok, new(Name, InitialState, FoldFn)}.
|
||||||
|
|
||||||
|
handle_call(get_state, _From, Proj) ->
|
||||||
|
{reply, state(Proj), Proj}.
|
||||||
|
|
||||||
|
handle_cast({fold, Activity}, Proj) ->
|
||||||
|
{noreply, fold_activity(Proj, Activity)}.
|
||||||
|
|
||||||
|
handle_info(_, Proj) -> {noreply, Proj}.
|
||||||
120
next/kernel/registry.erl
Normal file
120
next/kernel/registry.erl
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
-module(registry).
|
||||||
|
-behaviour(gen_server).
|
||||||
|
-export([new/0, kinds/0, register/4, lookup/3, list/2]).
|
||||||
|
-export([start_link/0, register/3, lookup/2, list/1, stop/0]).
|
||||||
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).
|
||||||
|
|
||||||
|
%% Pure-functional registry for the seven bootstrap kinds.
|
||||||
|
%%
|
||||||
|
%% State is a property list keyed by kind atom; each kind's value
|
||||||
|
%% is itself a property list of {Name, Entry} pairs. Entry is
|
||||||
|
%% opaque — typically a proplist with :cid, :schema, :semantics,
|
||||||
|
%% :supersedes fields, but the registry doesn't enforce that here.
|
||||||
|
%%
|
||||||
|
%% A gen_server wrapper (Step 5b) will own the global registry
|
||||||
|
%% process; the pure functions in this module remain the canonical
|
||||||
|
%% API and are usable for tests and for offline projection-replay.
|
||||||
|
%%
|
||||||
|
%% Return shapes:
|
||||||
|
%% new/0 -> State
|
||||||
|
%% kinds/0 -> [Atom, ...]
|
||||||
|
%% register/4 -> {ok, NewState} | {error, unknown_kind}
|
||||||
|
%% lookup/3 -> {ok, Entry} | not_found | {error, unknown_kind}
|
||||||
|
%% list/2 -> [{Name, Entry}, ...] | {error, unknown_kind}
|
||||||
|
|
||||||
|
new() -> [].
|
||||||
|
|
||||||
|
kinds() ->
|
||||||
|
[activity_types, object_types, projections,
|
||||||
|
validators, codecs, sig_suites, audience].
|
||||||
|
|
||||||
|
register(Kind, Name, Entry, State) ->
|
||||||
|
case is_valid_kind(Kind) of
|
||||||
|
false -> {error, unknown_kind};
|
||||||
|
true ->
|
||||||
|
Entries = kind_entries(Kind, State),
|
||||||
|
Updated = put_pair(Name, Entry, Entries),
|
||||||
|
{ok, set_kind_entries(Kind, Updated, State)}
|
||||||
|
end.
|
||||||
|
|
||||||
|
lookup(Kind, Name, State) ->
|
||||||
|
case is_valid_kind(Kind) of
|
||||||
|
false -> {error, unknown_kind};
|
||||||
|
true ->
|
||||||
|
find_pair(Name, kind_entries(Kind, State))
|
||||||
|
end.
|
||||||
|
|
||||||
|
list(Kind, State) ->
|
||||||
|
case is_valid_kind(Kind) of
|
||||||
|
false -> {error, unknown_kind};
|
||||||
|
true -> kind_entries(Kind, State)
|
||||||
|
end.
|
||||||
|
|
||||||
|
%% ── Internal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
is_valid_kind(K) -> lists:member(K, kinds()).
|
||||||
|
|
||||||
|
kind_entries(Kind, State) ->
|
||||||
|
case find_pair(Kind, State) of
|
||||||
|
not_found -> [];
|
||||||
|
{ok, V} -> V
|
||||||
|
end.
|
||||||
|
|
||||||
|
set_kind_entries(Kind, Entries, State) ->
|
||||||
|
put_pair(Kind, Entries, State).
|
||||||
|
|
||||||
|
put_pair(K, V, []) -> [{K, V}];
|
||||||
|
put_pair(K, V, [{K, _} | Rest]) -> [{K, V} | Rest];
|
||||||
|
put_pair(K, V, [P | Rest]) -> [P | put_pair(K, V, Rest)].
|
||||||
|
|
||||||
|
find_pair(_, []) -> not_found;
|
||||||
|
find_pair(K, [{K, V} | _]) -> {ok, V};
|
||||||
|
find_pair(K, [_ | Rest]) -> find_pair(K, Rest).
|
||||||
|
|
||||||
|
%% ── Step 5b: gen_server wrapper ─────────────────────────────────
|
||||||
|
%%
|
||||||
|
%% The named process owns the registry state; concurrent readers
|
||||||
|
%% and writers serialize through gen_server:call. The pure /3 and
|
||||||
|
%% /4 functions remain available for offline projection-replay and
|
||||||
|
%% for tests that don't need a process at all.
|
||||||
|
%%
|
||||||
|
%% Port notes: gen_server:start_link returns the raw Pid (not
|
||||||
|
%% `{ok, Pid}` as in OTP). `?MODULE` macro is unsupported here, so
|
||||||
|
%% the registered name is the literal `registry` atom in every call.
|
||||||
|
|
||||||
|
start_link() ->
|
||||||
|
Pid = gen_server:start_link(registry, []),
|
||||||
|
erlang:register(registry, Pid),
|
||||||
|
Pid.
|
||||||
|
|
||||||
|
stop() ->
|
||||||
|
R = gen_server:call(registry, '$gen_stop'),
|
||||||
|
erlang:unregister(registry),
|
||||||
|
R.
|
||||||
|
|
||||||
|
register(Kind, Name, Entry) ->
|
||||||
|
gen_server:call(registry, {register, Kind, Name, Entry}).
|
||||||
|
|
||||||
|
lookup(Kind, Name) ->
|
||||||
|
gen_server:call(registry, {lookup, Kind, Name}).
|
||||||
|
|
||||||
|
list(Kind) ->
|
||||||
|
gen_server:call(registry, {list, Kind}).
|
||||||
|
|
||||||
|
%% gen_server callbacks
|
||||||
|
|
||||||
|
init(_) -> {ok, new()}.
|
||||||
|
|
||||||
|
handle_call({register, Kind, Name, Entry}, _From, State) ->
|
||||||
|
case register(Kind, Name, Entry, State) of
|
||||||
|
{ok, NewState} -> {reply, ok, NewState};
|
||||||
|
{error, R} -> {reply, {error, R}, State}
|
||||||
|
end;
|
||||||
|
handle_call({lookup, Kind, Name}, _From, State) ->
|
||||||
|
{reply, lookup(Kind, Name, State), State};
|
||||||
|
handle_call({list, Kind}, _From, State) ->
|
||||||
|
{reply, list(Kind, State), State}.
|
||||||
|
|
||||||
|
handle_cast(_, S) -> {noreply, S}.
|
||||||
|
|
||||||
|
handle_info(_, S) -> {noreply, S}.
|
||||||
41
next/kernel/sandbox.erl
Normal file
41
next/kernel/sandbox.erl
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-module(sandbox).
|
||||||
|
-export([eval_pure/2, eval_pure/3]).
|
||||||
|
|
||||||
|
%% Sandboxed evaluation of an Erlang fun.
|
||||||
|
%%
|
||||||
|
%% eval_pure/2(Fun, Arg) -> {ok, Result} | {error, Reason}
|
||||||
|
%% eval_pure/3(Fun, Arg1, Arg2) -> {ok, Result} | {error, Reason}
|
||||||
|
%%
|
||||||
|
%% The 3-arity variant matches the (Activity, State) -> NewState
|
||||||
|
%% shape of projection folds. The projection scheduler can wrap
|
||||||
|
%% every fold call in `sandbox:eval_pure(Fun, Act, State)` to
|
||||||
|
%% ensure a misbehaving fold body can't crash the projection
|
||||||
|
%% gen_server.
|
||||||
|
%%
|
||||||
|
%% v1 sandboxing is just the try/catch envelope: no gas budget,
|
||||||
|
%% no IO denial, no environment stripping. Real sandboxing lands
|
||||||
|
%% with SX-source eval (the fold body would then be an SX form
|
||||||
|
%% evaluated under the spec/harness platform). The API shape is
|
||||||
|
%% stable — callers don't need to change when that arrives.
|
||||||
|
|
||||||
|
%% Port note: this Erlang implementation catches by explicit
|
||||||
|
%% class names (throw, error, exit) rather than the open
|
||||||
|
%% `Class:Reason` pattern. The wrappers below enumerate the three.
|
||||||
|
|
||||||
|
eval_pure(Fun, Arg) ->
|
||||||
|
try Fun(Arg) of
|
||||||
|
Result -> {ok, Result}
|
||||||
|
catch
|
||||||
|
throw:Reason -> {error, {throw, Reason}};
|
||||||
|
error:Reason -> {error, {error, Reason}};
|
||||||
|
exit:Reason -> {error, {exit, Reason}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
eval_pure(Fun, Arg1, Arg2) ->
|
||||||
|
try Fun(Arg1, Arg2) of
|
||||||
|
Result -> {ok, Result}
|
||||||
|
catch
|
||||||
|
throw:Reason -> {error, {throw, Reason}};
|
||||||
|
error:Reason -> {error, {error, Reason}};
|
||||||
|
exit:Reason -> {error, {exit, Reason}}
|
||||||
|
end.
|
||||||
105
next/kernel/term_codec.erl
Normal file
105
next/kernel/term_codec.erl
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
-module(term_codec).
|
||||||
|
-export([encode/1, decode/1]).
|
||||||
|
|
||||||
|
%% Erlang-side term <-> binary codec, built on the substrate fixes from
|
||||||
|
%% commits 24e3bf53 (binary_to_list / list_to_binary), 3d80bd8c ($X char
|
||||||
|
%% literals), 4852cca9 (atom_to_list / integer_to_list charlists).
|
||||||
|
%%
|
||||||
|
%% Wire format (netstring-ish; all length headers ASCII decimal):
|
||||||
|
%%
|
||||||
|
%% atom $a Len $: NameBytes
|
||||||
|
%% integer $i Len $: DecimalBytes (negative ints carry leading $-)
|
||||||
|
%% binary $b Len $: RawBytes
|
||||||
|
%% tuple $t Count $: Enc1 Enc2 ... Encn
|
||||||
|
%% list $l Count $: Enc1 Enc2 ... Encn (proper list)
|
||||||
|
%% nil $l $0 $: (empty list)
|
||||||
|
%%
|
||||||
|
%% Each Enc is itself one of these forms — recursive. The format is
|
||||||
|
%% byte-clean: binary bodies may contain any byte (newlines, NULs, etc.),
|
||||||
|
%% so callers can frame entries with a 4-byte big-endian length prefix
|
||||||
|
%% (Step 3b on-disk segment writer's job).
|
||||||
|
|
||||||
|
%% encode/1: term -> binary
|
||||||
|
encode(T) when is_atom(T) ->
|
||||||
|
Cs = atom_to_list(T),
|
||||||
|
list_to_binary([$a, integer_to_list(length(Cs)), $:, Cs]);
|
||||||
|
encode(T) when is_integer(T) ->
|
||||||
|
Cs = integer_to_list(T),
|
||||||
|
list_to_binary([$i, integer_to_list(length(Cs)), $:, Cs]);
|
||||||
|
encode(T) when is_binary(T) ->
|
||||||
|
list_to_binary([$b, integer_to_list(byte_size(T)), $:, T]);
|
||||||
|
encode(T) when is_tuple(T) ->
|
||||||
|
L = tuple_to_list(T),
|
||||||
|
list_to_binary([$t, integer_to_list(length(L)), $:,
|
||||||
|
[encode(E) || E <- L]]);
|
||||||
|
encode([]) ->
|
||||||
|
list_to_binary([$l, $0, $:]);
|
||||||
|
encode(T) when is_list(T) ->
|
||||||
|
list_to_binary([$l, integer_to_list(length(T)), $:,
|
||||||
|
[encode(E) || E <- T]]).
|
||||||
|
|
||||||
|
%% decode/1: binary -> {ok, Term, RestBinary} | {error, badform}
|
||||||
|
%% On success returns the remaining unconsumed bytes so callers can
|
||||||
|
%% stream-decode multiple frames from one buffer.
|
||||||
|
decode(B) when is_binary(B) ->
|
||||||
|
decode_chars(binary_to_list(B)).
|
||||||
|
|
||||||
|
decode_chars([$a | Rest]) ->
|
||||||
|
{Len, Rest1} = read_len(Rest, 0),
|
||||||
|
Rest2 = strip_colon(Rest1),
|
||||||
|
{NameChars, Rest3} = split_at(Len, Rest2),
|
||||||
|
{ok, list_to_atom(NameChars), list_to_binary(Rest3)};
|
||||||
|
decode_chars([$i | Rest]) ->
|
||||||
|
{Len, Rest1} = read_len(Rest, 0),
|
||||||
|
Rest2 = strip_colon(Rest1),
|
||||||
|
{NumChars, Rest3} = split_at(Len, Rest2),
|
||||||
|
{ok, list_to_integer(NumChars), list_to_binary(Rest3)};
|
||||||
|
decode_chars([$b | Rest]) ->
|
||||||
|
{Len, Rest1} = read_len(Rest, 0),
|
||||||
|
Rest2 = strip_colon(Rest1),
|
||||||
|
{Bytes, Rest3} = split_at(Len, Rest2),
|
||||||
|
{ok, list_to_binary(Bytes), list_to_binary(Rest3)};
|
||||||
|
decode_chars([$t | Rest]) ->
|
||||||
|
{N, Rest1} = read_len(Rest, 0),
|
||||||
|
Rest2 = strip_colon(Rest1),
|
||||||
|
{Elems, Rest3} = decode_n(N, Rest2, []),
|
||||||
|
{ok, list_to_tuple(Elems), list_to_binary(Rest3)};
|
||||||
|
decode_chars([$l | Rest]) ->
|
||||||
|
{N, Rest1} = read_len(Rest, 0),
|
||||||
|
Rest2 = strip_colon(Rest1),
|
||||||
|
{Elems, Rest3} = decode_n(N, Rest2, []),
|
||||||
|
{ok, Elems, list_to_binary(Rest3)};
|
||||||
|
decode_chars(_) ->
|
||||||
|
{error, badform}.
|
||||||
|
|
||||||
|
read_len([C | Rest], Acc) when C >= $0, C =< $9 ->
|
||||||
|
read_len(Rest, Acc * 10 + C - $0);
|
||||||
|
read_len([$- | Rest], 0) ->
|
||||||
|
%% Leading minus for negative integer-body lengths is invalid for
|
||||||
|
%% lengths, but appears inside integer-body bytes (handled in
|
||||||
|
%% the body, not here — read_len only consumes digits before $:).
|
||||||
|
{0, [$- | Rest]};
|
||||||
|
read_len(Rest, Acc) ->
|
||||||
|
{Acc, Rest}.
|
||||||
|
|
||||||
|
strip_colon([$: | Rest]) -> Rest;
|
||||||
|
strip_colon(Other) -> erlang:error({badform, Other}).
|
||||||
|
|
||||||
|
split_at(0, Rest) -> {[], Rest};
|
||||||
|
split_at(N, [H | T]) ->
|
||||||
|
{Hs, Tl} = split_at(N - 1, T),
|
||||||
|
{[H | Hs], Tl};
|
||||||
|
split_at(_, []) ->
|
||||||
|
erlang:error({badform, short}).
|
||||||
|
|
||||||
|
decode_n(0, Rest, Acc) ->
|
||||||
|
{lists:reverse(Acc), Rest};
|
||||||
|
decode_n(N, Bytes, Acc) ->
|
||||||
|
{Term, Rest} = decode_one(Bytes),
|
||||||
|
decode_n(N - 1, Rest, [Term | Acc]).
|
||||||
|
|
||||||
|
decode_one(Bytes) ->
|
||||||
|
case decode_chars(Bytes) of
|
||||||
|
{ok, Term, RestBin} -> {Term, binary_to_list(RestBin)};
|
||||||
|
{error, R} -> erlang:error({badform, R})
|
||||||
|
end.
|
||||||
0
next/tests/.gitkeep
Normal file
0
next/tests/.gitkeep
Normal file
164
next/tests/actor_lifecycle.sh
Executable file
164
next/tests/actor_lifecycle.sh
Executable file
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# next/tests/actor_lifecycle.sh — m2 Step 2c end-to-end test.
|
||||||
|
#
|
||||||
|
# Ties Step 2a artefacts (genesis Person/Service/Group SX files),
|
||||||
|
# Step 2b projection (actor_state.erl), and Step 2c bootstrap
|
||||||
|
# (nx_kernel:bootstrap_actor/4) together. Profiles bootstrap as
|
||||||
|
# Create{Person|Service|Group} activities; the actor_state projection
|
||||||
|
# folds them into the per-actor profile registry.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Two actors share signing-key bytes (each in its own AS). The
|
||||||
|
# profile's :public_keys list is what gets wrapped in the Create
|
||||||
|
# object; the kernel-side AS proplist (built by bootstrap_actor/4
|
||||||
|
# from :public_keys) is what envelope:verify_signature reads.
|
||||||
|
ALICE_KM='AliceK = <<1,2,3,4>>, AliceKey = [{id, k1}, {created, 0}, {value, AliceK}], AlicePks = [AliceKey], AliceKS = [{key_id, k1}, {algorithm, ed25519}, {value, AliceK}],'
|
||||||
|
BOB_KM='BobK = <<5,6,7,8>>, BobKey = [{id, k1}, {created, 0}, {value, BobK}], BobPks = [BobKey], BobKS = [{key_id, k1}, {algorithm, ed25519}, {value, BobK}],'
|
||||||
|
ALICE_PROFILE='AliceProfile = [{type, person}, {name, alice_n}, {preferredUsername, alice_local}, {public_keys, AlicePks}],'
|
||||||
|
BOB_PROFILE='BobProfile = [{type, service}, {name, bobbot_n}, {preferredUsername, bobbot_local}, {public_keys, BobPks}],'
|
||||||
|
|
||||||
|
# actor_state projection wiring — fold_fn from actor_state:fold_fn/0,
|
||||||
|
# initial state = actor_state:new().
|
||||||
|
PROJ_SETUP='projection:start_link(actors, actor_state:new(), actor_state:fold_fn()),'
|
||||||
|
|
||||||
|
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/outbox.erl\")) :name)")
|
||||||
|
(epoch 7)
|
||||||
|
(eval "(get (erlang-load-module (file-read \"next/kernel/projection.erl\")) :name)")
|
||||||
|
(epoch 8)
|
||||||
|
(eval "(get (erlang-load-module (file-read \"next/kernel/actor_state.erl\")) :name)")
|
||||||
|
(epoch 9)
|
||||||
|
(eval "(get (erlang-load-module (file-read \"next/kernel/nx_kernel.erl\")) :name)")
|
||||||
|
|
||||||
|
;; Pure: bootstrap_actor/4 on a fresh kernel publishes Create and
|
||||||
|
;; returns {ok, Result, S}.
|
||||||
|
(epoch 10)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} case nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()) of {ok, _, _} -> ok; _ -> bad end\") :name)")
|
||||||
|
|
||||||
|
;; Pure: after bootstrap, log_tip = 1, has_actor true
|
||||||
|
(epoch 11)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), nx_kernel:has_actor(alice, S) andalso nx_kernel:actor_log_tip(alice, S) =:= 1\") :name)")
|
||||||
|
|
||||||
|
;; Pure: log entry is a Create with object's type = person
|
||||||
|
(epoch 12)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), {ok, L} = nx_kernel:actor_log_state(alice, S), [E] = log:entries(L), {ok, create} = envelope:get_field(type, E), {ok, Obj} = envelope:get_field(object, E), envelope:get_field(type, Obj) =:= {ok, person}\") :name)")
|
||||||
|
|
||||||
|
;; Pure: bootstrap into existing kernel with another actor
|
||||||
|
(epoch 13)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S1} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), {ok, _, S2} = nx_kernel:bootstrap_actor(bobbot, BobProfile, BobKS, S1), nx_kernel:actors(S2) =:= [alice, bobbot]\") :name)")
|
||||||
|
|
||||||
|
;; Pure: two actors have independent log_tips
|
||||||
|
(epoch 14)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S1} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), {ok, _, S2} = nx_kernel:bootstrap_actor(bobbot, BobProfile, BobKS, S1), {nx_kernel:actor_log_tip(alice, S2), nx_kernel:actor_log_tip(bobbot, S2)} =:= {1, 1}\") :name)")
|
||||||
|
|
||||||
|
;; Pure: duplicate bootstrap_actor returns already_present
|
||||||
|
(epoch 15)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} {ok, _, S1} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, nx_kernel:new()), case nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS, S1) of {error, already_present, _} -> ok; _ -> bad end\") :name)")
|
||||||
|
|
||||||
|
;; gen_server: bootstrap_actor/3 publishes + actor_state projection captures profile
|
||||||
|
(epoch 16)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:with_projections_for(seed, [actors]), {ok, _} = nx_kernel:bootstrap_actor(alice, AliceProfile, AliceKS), nx_kernel:has_actor(seed, nx_kernel:query()) andalso nx_kernel:has_actor(alice, nx_kernel:query())\") :name)")
|
||||||
|
|
||||||
|
;; gen_server: actor_state projection captures the bootstrapped Person profile
|
||||||
|
(epoch 17)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:with_projections_for(alice_pre, [actors]), nx_kernel:add_actor(alice_pre, AliceKS, [{public_keys, AlicePks}]), nx_kernel:with_projections_for(alice_pre, [actors]), {ok, _} = nx_kernel:publish_to(alice_pre, [{type, create}, {object, [{type, person}, {name, alice_n}, {preferredUsername, alice_local}, {public_keys, AlicePks}]}]), {ok, Profile} = actor_state:lookup(alice_pre, projection:query(actors)), actor_state:profile_type(Profile) =:= person andalso actor_state:profile_name(Profile) =:= alice_n\") :name)")
|
||||||
|
|
||||||
|
;; gen_server: Service profile lands as service in actor_state
|
||||||
|
(epoch 18)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, BobKS, [{public_keys, BobPks}]), ${PROJ_SETUP} nx_kernel:add_actor(bobbot, BobKS, [{public_keys, BobPks}]), nx_kernel:with_projections_for(bobbot, [actors]), {ok, _} = nx_kernel:publish_to(bobbot, [{type, create}, {object, [{type, service}, {name, bobbot_n}, {public_keys, BobPks}]}]), {ok, Profile} = actor_state:lookup(bobbot, projection:query(actors)), actor_state:profile_type(Profile) =:= service\") :name)")
|
||||||
|
|
||||||
|
;; gen_server: Group profile lands as group in actor_state
|
||||||
|
(epoch 19)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:add_actor(wg1, AliceKS, [{public_keys, AlicePks}]), nx_kernel:with_projections_for(wg1, [actors]), {ok, _} = nx_kernel:publish_to(wg1, [{type, create}, {object, [{type, group}, {name, working_group_n}, {public_keys, AlicePks}]}]), {ok, Profile} = actor_state:lookup(wg1, projection:query(actors)), actor_state:profile_type(Profile) =:= group\") :name)")
|
||||||
|
|
||||||
|
;; Sanity: profile captures :preferredUsername + :public_keys from the Create object
|
||||||
|
(epoch 20)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} ${BOB_KM} ${ALICE_PROFILE} ${BOB_PROFILE} nx_kernel:start_link(seed, AliceKS, [{public_keys, AlicePks}]), ${PROJ_SETUP} nx_kernel:add_actor(alice, AliceKS, [{public_keys, AlicePks}]), nx_kernel:with_projections_for(alice, [actors]), {ok, _} = nx_kernel:publish_to(alice, [{type, create}, {object, [{type, person}, {name, alice_n}, {preferredUsername, alice_local}, {public_keys, AlicePks}]}]), {ok, Profile} = actor_state:lookup(alice, projection:query(actors)), actor_state:profile_field(preferredUsername, Profile) =:= {ok, alice_local} andalso actor_state:profile_field(public_keys, Profile) =:= {ok, AlicePks}\") :name)")
|
||||||
|
|
||||||
|
;; Pure: profile defaults to person when :type missing
|
||||||
|
(epoch 21)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} TypelessProfile = [{name, alice_n}, {public_keys, AlicePks}], {ok, _, S} = nx_kernel:bootstrap_actor(alice, TypelessProfile, AliceKS, nx_kernel:new()), {ok, L} = nx_kernel:actor_log_state(alice, S), [E] = log:entries(L), {ok, Obj} = envelope:get_field(object, E), envelope:get_field(type, Obj) =:= {ok, person}\") :name)")
|
||||||
|
|
||||||
|
;; Pure: empty profile :public_keys defaults to []
|
||||||
|
(epoch 22)
|
||||||
|
(eval "(get (erlang-eval-ast \"${ALICE_KM} EmptyProfile = [{type, person}, {name, alice_n}], case nx_kernel:bootstrap_actor(alice, EmptyProfile, AliceKS, nx_kernel:new()) of {ok, _, _} -> ok; {error, _, _} -> ok end\") :name)")
|
||||||
|
EPOCHS
|
||||||
|
|
||||||
|
OUTPUT=$(timeout 240 "$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 2 "gen_server loaded" "gen_server"
|
||||||
|
check 9 "nx_kernel loaded" "nx_kernel"
|
||||||
|
check 10 "bootstrap_actor/4 -> {ok, _, _}" "ok"
|
||||||
|
check 11 "bootstrap_actor advances log_tip" "true"
|
||||||
|
check 12 "log entry is Create{Person}" "true"
|
||||||
|
check 13 "two actors live in one kernel" "true"
|
||||||
|
check 14 "independent log_tips after boot" "true"
|
||||||
|
check 15 "duplicate boot -> already_present" "ok"
|
||||||
|
check 16 "gen_server bootstrap_actor/3" "true"
|
||||||
|
check 17 "actor_state captures Person" "true"
|
||||||
|
check 18 "actor_state captures Service" "true"
|
||||||
|
check 19 "actor_state captures Group" "true"
|
||||||
|
check 20 "profile carries preferredUsername" "true"
|
||||||
|
check 21 "typeless profile defaults Person" "true"
|
||||||
|
check 22 "empty public_keys handled" "ok"
|
||||||
|
|
||||||
|
TOTAL=$((PASS+FAIL))
|
||||||
|
if [ $FAIL -eq 0 ]; then
|
||||||
|
echo "ok $PASS/$TOTAL next/tests/actor_lifecycle.sh passed"
|
||||||
|
else
|
||||||
|
echo "FAIL $PASS/$TOTAL passed, $FAIL failed:"
|
||||||
|
echo "$ERRORS"
|
||||||
|
fi
|
||||||
|
[ $FAIL -eq 0 ]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user