diff --git a/Cargo.lock b/Cargo.lock index ec51394b..944ffff6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,12 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "base64" version = "0.22.1" @@ -271,6 +277,17 @@ version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d953932541249c91e3fa70a75ff1e52adc62979a2a8132145d4b9b3e6d1a9b6a" +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "endian-type" version = "0.1.2" @@ -340,6 +357,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -351,9 +377,50 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] [[package]] name = "gimli" @@ -421,6 +488,183 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -472,6 +716,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.29" @@ -544,7 +794,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9676d58588b220f7af69d7aa86108042d2acaf21dd24c641a6d9ef3c4e193ba" dependencies = [ "pd-host-function 0.22.2", - "syn", + "syn 2.0.117", ] [[package]] @@ -555,7 +805,7 @@ dependencies = [ "pd-vm", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "trybuild", ] @@ -567,7 +817,7 @@ checksum = "d9c941589fbbb839a40f7b80595d7b8f3742a8811268d787218f0c45c274d1f9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -575,7 +825,7 @@ name = "pd-host-schema" version = "0.1.0" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -589,6 +839,10 @@ dependencies = [ "cranelift-module", "cranelift-native", "futures-channel", + "futures-util", + "http-body-util", + "hyper", + "hyper-util", "libc", "paste", "pd-edge-abi", @@ -597,12 +851,16 @@ dependencies = [ "regex", "rt-format", "rusqlite", + "rustls", "rustyline", "self_cell", "serde", "serde_json", - "syn", + "syn 2.0.117", "tokio", + "tokio-rustls", + "url", + "webpki-roots", "windows-sys 0.59.0", ] @@ -623,6 +881,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -635,6 +899,15 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -718,6 +991,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rt-format" version = "0.3.1" @@ -761,6 +1048,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustscript" version = "0.1.0" @@ -824,7 +1145,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -865,6 +1186,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -887,6 +1214,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -898,6 +1231,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -919,6 +1274,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.49.0" @@ -943,7 +1308,17 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", ] [[package]] @@ -985,6 +1360,12 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "trybuild" version = "1.0.120" @@ -1018,6 +1399,30 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1036,6 +1441,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1063,6 +1477,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1175,6 +1598,35 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.56" @@ -1192,7 +1644,67 @@ checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d13fdac0..25741efb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,17 @@ name = "vm" default = ["runtime", "cli", "cranelift-jit"] runtime = [] async = ["runtime", "dep:tokio"] +http-client = [ + "async", + "dep:futures-util", + "dep:http-body-util", + "dep:hyper", + "dep:hyper-util", + "dep:rustls", + "dep:tokio-rustls", + "dep:url", + "dep:webpki-roots", +] sqlite = ["runtime", "dep:rusqlite"] edge-abi = [ "dep:edge_abi", @@ -79,7 +90,6 @@ cranelift-module = { version = "0.129.1", optional = true } cranelift-native = { version = "0.129.1", optional = true } pd-host-function = { path = "./pd-host-function", version = "0.1.0" } rusqlite = { version = "0.32", default-features = false, features = ["bundled", "hooks", "limits"], optional = true } -tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "sync", "fs", "io-util", "process"], optional = true } edge_abi = { package = "pd-edge-abi", version = "0.1.1", default-features = false, optional = true } futures-channel = "0.3" paste = "1" @@ -90,6 +100,17 @@ rt-format = "0.3.1" self_cell = "1" rustyline = { version = "14", optional = true } +[target.'cfg(not(target_family = "wasm"))'.dependencies] +http-body-util = { version = "0.1", optional = true } +hyper = { version = "1", default-features = false, features = ["client", "http1"], optional = true } +hyper-util = { version = "0.1", default-features = false, features = ["tokio"], optional = true } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"], optional = true } +tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"], optional = true } +url = { version = "2", optional = true } +futures-util = { version = "0.3", optional = true } +tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "sync", "fs", "io-util", "process", "macros"], optional = true } +webpki-roots = { version = "1", optional = true } + [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.59", features = ["Win32_System_Diagnostics_Debug", "Win32_System_Memory", "Win32_System_ProcessStatus", "Win32_System_Threading"] } @@ -99,6 +120,11 @@ libc = "0.2" [dev-dependencies] pd-host-schema = { path = "./crates/pd-host-schema", version = "0.1.0" } syn = { version = "2", features = ["full"] } + +[target.'cfg(not(target_family = "wasm"))'.dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] } + +[target.'cfg(target_family = "wasm")'.dev-dependencies] tokio = { version = "1", features = ["macros", "rt", "time", "sync"] } [[test]] @@ -126,6 +152,36 @@ name = "host_context_arch_tests" path = "tests/host_context_arch_tests.rs" required-features = ["runtime"] +[[test]] +name = "host_named_struct_foundation_tests" +path = "tests/host_named_struct_foundation_tests.rs" +required-features = ["runtime"] + +[[test]] +name = "http_host_tests" +path = "tests/vm/http_host_tests.rs" +required-features = ["runtime", "http-client"] + +[[test]] +name = "http_sse_tests" +path = "tests/vm/http_sse_tests.rs" +required-features = ["runtime", "http-client"] + +[[test]] +name = "http_named_struct_contract_tests" +path = "tests/http_named_struct_contract_tests.rs" +required-features = ["runtime", "http-client"] + +[[test]] +name = "io_http_coexistence_tests" +path = "tests/vm/io_http_coexistence_tests.rs" +required-features = ["runtime", "http-client"] + +[[test]] +name = "http_feature_gating_tests" +path = "tests/http_feature_gating_tests.rs" +required-features = ["runtime"] + [build-dependencies] pd-host-schema = { path = "./crates/pd-host-schema", version = "0.1.0" } syn = { version = "2", features = ["full"] } diff --git a/build.rs b/build.rs index a9ddba58..b15573e8 100644 --- a/build.rs +++ b/build.rs @@ -129,6 +129,17 @@ struct NamespaceDecl { runtime_supported_on_wasm: bool, } +/// HTTP/SSE is a native transport extension. Keep this predicate identical to +/// the `cfg` boundary used by the runtime and public exports: the Cargo +/// feature remains selectable on wasm, but it must not publish transport +/// sources or generated host/catalog entries there. +pub(crate) fn http_transport_enabled(http_client_feature: bool, target_family: &str) -> bool { + http_client_feature + && !target_family + .split(',') + .any(|family| family.trim() == "wasm") +} + #[derive(Clone, Debug)] struct Group<'a> { key: String, @@ -166,7 +177,8 @@ fn main() { catalog.retain(|entry| !entry.source_name.starts_with("sqlite::")); } - let host_sources = vec![ + let target_family = env::var("CARGO_CFG_TARGET_FAMILY").expect("missing target family"); + let mut host_sources = vec![ SourceSpec { path: "src/builtins/runtime/host.rs".to_string(), module: "host".to_string(), @@ -178,6 +190,21 @@ fn main() { category: SourceCategory::DefaultHost, }, ]; + if http_transport_enabled( + env::var_os("CARGO_FEATURE_HTTP_CLIENT").is_some(), + &target_family, + ) { + host_sources.push(SourceSpec { + path: "src/builtins/runtime/http/mod.rs".to_string(), + module: "http".to_string(), + category: SourceCategory::DefaultHost, + }); + host_sources.push(SourceSpec { + path: "src/builtins/runtime/http/sse.rs".to_string(), + module: "http::sse".to_string(), + category: SourceCategory::DefaultHost, + }); + } let async_enabled = env::var_os("CARGO_FEATURE_ASYNC").is_some(); let target_arch = env::var("CARGO_CFG_TARGET_ARCH").expect("missing target architecture"); let builtin_sources = builtin_source_specs(&namespaces, async_enabled, &target_arch); @@ -2216,11 +2243,21 @@ fn find_matching_paren(source: &str) -> usize { #[cfg(test)] mod tests { use super::{ - HostExecutionKind, NamespaceDecl, SourceCategory, builtin_source_specs, parse_source_file, - select_io_source_path, + HostExecutionKind, NamespaceDecl, SourceCategory, builtin_source_specs, + http_transport_enabled, parse_source_file, select_io_source_path, }; use std::path::Path; + #[test] + fn http_transport_predicate_matches_source_and_catalog_boundary() { + assert!(http_transport_enabled(true, "unix")); + assert!(http_transport_enabled(true, "windows")); + assert!(!http_transport_enabled(true, "wasm")); + assert!(!http_transport_enabled(true, "wasm,unix")); + assert!(!http_transport_enabled(false, "unix")); + assert!(!http_transport_enabled(false, "wasm")); + } + fn io_namespace() -> NamespaceDecl { NamespaceDecl { namespace: "io".to_string(), diff --git a/crates/rustscript/tests/lsp_resource_types.rs b/crates/rustscript/tests/lsp_resource_types.rs index 7c84b1d2..0e6e1f4f 100644 --- a/crates/rustscript/tests/lsp_resource_types.rs +++ b/crates/rustscript/tests/lsp_resource_types.rs @@ -249,7 +249,7 @@ const ENTRY_URI: &str = "file:///tmp/rustscript-lsp-fixture/main.rss"; const CLEAN_SOURCE: &str = r#"use sqlite; fn main() { let db = sqlite::open({}); - sqlite::query(&db, "SELECT 1", {}, {}); + sqlite::query(&db, "SELECT 1", [], {}); } "#; @@ -258,7 +258,7 @@ fn main() { const WRONG_TYPE_SOURCE: &str = r#"use sqlite; fn main() { let db = sqlite::open({}); - sqlite::query("NOT_A_DB", "SELECT 1", {}, {}); + sqlite::query("NOT_A_DB", "SELECT 1", [], {}); } "#; @@ -538,6 +538,18 @@ fn signature_help_shows_borrow_resource_and_value_params() { label.contains("sql: string"), "signature must show the value parameter: {label}" ); + assert!( + label.contains("params: array"), + "signature must show typed SQLite parameters: {label}" + ); + assert!( + label.contains("limits: SqliteLimits"), + "signature must show typed SQLite limits: {label}" + ); + assert!( + label.contains("-> SqliteQueryResult"), + "signature must show the typed SQLite query result: {label}" + ); } // --------------------------------------------------------------------------- @@ -739,7 +751,7 @@ fn run() { const MODULE_BAD_UTIL_SOURCE: &str = r#"use sqlite; pub fn helper() { let db = sqlite::open({}); - sqlite::query("NOT_A_DB", "SELECT 1", {}, {}); + sqlite::query("NOT_A_DB", "SELECT 1", [], {}); } "#; @@ -747,7 +759,7 @@ pub fn helper() { const MODULE_CLEAN_UTIL_SOURCE: &str = r#"use sqlite; pub fn helper() { let db = sqlite::open({}); - sqlite::query(&db, "SELECT 1", {}, {}); + sqlite::query(&db, "SELECT 1", [], {}); } "#; @@ -916,10 +928,10 @@ fn unicode_source_outbound_diagnostic_range_uses_utf16_columns() { client.request(1, "initialize", serde_json::json!({})); client.notify("initialized", serde_json::json!({})); // Same-line multibyte prefix, then a wrong-type call on the *same line*. - // `let s = "你好😀"; sqlite::query("NOT_A_DB", "SELECT 1", {}, {});` + // `let s = "你好😀"; sqlite::query("NOT_A_DB", "SELECT 1", [], {});` // The wrong-argument diagnostic must be reported with UTF-16 columns, so // a client re-navigating from the range lands on the callee. - let source = "use sqlite;\nlet s = \"\u{4f60}\u{597d}\u{1f600}\"; sqlite::query(\"NOT_A_DB\", \"SELECT 1\", {}, {});\n"; + let source = "use sqlite;\nlet s = \"\u{4f60}\u{597d}\u{1f600}\"; sqlite::query(\"NOT_A_DB\", \"SELECT 1\", [], {});\n"; open_doc(&mut client, ENTRY_URI, source); let params = client.recv_notification("textDocument/publishDiagnostics"); let diagnostics = params["diagnostics"].as_array().expect("diagnostics array"); @@ -1474,7 +1486,7 @@ const DISK_UTIL_GOOD: &str = "pub fn helper() -> int { 41 }\n"; const BUFFER_UTIL_GOOD: &str = "pub fn helper() -> int { 999 }\n"; /// Unsaved buffer version of `util.rss` with a wrong-type call (diagnostic). -const BUFFER_UTIL_BAD: &str = "use sqlite;\npub fn helper() -> int {\n let db = sqlite::open({});\n sqlite::query(\"NOT_A_DB\", \"SELECT 1\", {}, {});\n 0\n}\n"; +const BUFFER_UTIL_BAD: &str = "use sqlite;\npub fn helper() -> int {\n let db = sqlite::open({});\n sqlite::query(\"NOT_A_DB\", \"SELECT 1\", [], {});\n 0\n}\n"; /// A syntax error in `util.rss` (unterminated block) whose parser span should /// be reported under the module URI. diff --git a/docs/callable-runtime.md b/docs/callable-runtime.md index 67a4d05b..23e49e12 100644 --- a/docs/callable-runtime.md +++ b/docs/callable-runtime.md @@ -1,6 +1,6 @@ # Script call frames and callable values -RustScript bytecode format version 12 (VMBC v12) carries runtime script call frames, first-class callable values, the static builtin ID catalog, and the direct script-call opcode. Version 11 introduced frames, callable values, and the static catalog; version 12 adds `callscript` for statically resolved named calls. +RustScript bytecode format version 13 (VMBC v13) carries runtime script call frames, first-class callable values, the static builtin ID catalog, the direct script-call opcode, and an explicit guest named-struct declaration section. Version 11 introduced frames, callable values, and the static catalog; version 12 adds `callscript` for statically resolved named calls; version 13 frames guest struct declarations so a 4-byte zero trailer cannot be mistaken for an empty table. ## Bytecode contract @@ -18,7 +18,7 @@ The three call opcodes differ in who owns the callee and what the frame must pro - `callvalue` — the callee is a `Value::Callable` owned by the caller operand stack at the call site, and remains the caller's responsibility after the call. This path carries environments, closures, and any callable whose identity or capture state is runtime-valued. - `callscript` — the callee is owned by program callable metadata (the prototype table). The frame contributes only `argc` arguments and no callable value, but unlike `call` the callee is a script function rather than a builtin, so the call enters a new script frame with its own local base. -VMBC v12 is the current format. It decodes the legacy v11 stream without host-schema metadata, while v12 carries full host schemas and callable metadata. Unknown versions and malformed resource schemas are rejected deterministically. PDRC v6 recordings and AOT artifacts (format 8, ABI 8) use their corresponding bumped versions and include callable metadata in cache identity. +VMBC v13 is the current format. It decodes the legacy v11 stream without host-schema metadata and the v12 stream without a named-struct section, while v13 carries full host schemas, callable metadata, and an explicit guest named-struct table. Unknown versions and malformed resource schemas are rejected deterministically. PDRC v6 recordings and AOT artifacts (format 8, ABI 8) use their corresponding bumped versions and include callable metadata in cache identity. ## Static builtin IDs @@ -27,7 +27,7 @@ Every VM-visible builtin (ordinary, internal, and special-call) has one explicit - **Immutable explicit IDs.** IDs never change once assigned. Adding or reordering catalog entries never renumbers existing entries; new builtins take the next free ID in their documented block (extension `0x0000..=0xFF8F` for future builtins and host imports, special-call `0xFF90..=0xFFA1`, ordinary `0xFFA2..=0xFFFF`). The reserved sentinel gap `0xFF90..=0xFF92` stays unassigned. - **Build-time validation.** The build fails on duplicate IDs, duplicate source names, duplicate Rust variants, out-of-block IDs, class/gate inconsistencies, a discovered runtime callable without an explicit ID, or a catalog entry without a runtime callable. - **Shared std/no-std IDs.** `pd-vm-nostd` dispatches on the same static indices through the checked-in generated mirror `pd-vm-nostd/src/generated_builtin_ids.rs`; the workspace test `static_builtin_ids_are_frozen` fails when the mirror drifts from the catalog. -- **Format breaks are permanent.** The static ID migration bumped VMBC to v11 (and the internal bytecode ABI to 11); the `callscript` opcode break bumped both to v12. Versions below the current format are rejected, never decoded. +- **Format breaks are permanent.** The static ID migration bumped VMBC to v11 (and the internal bytecode ABI to 11); the `callscript` opcode break bumped both to v12; the guest named-struct section bumped both to v13. Versions below the current encode format are not rewritten in place: v11/v12 remain readable only in their original framing. ## Runtime model @@ -78,10 +78,26 @@ PDRC recordings preserve full execution-frame metadata. Callable environments us Polling drives execution and provides backpressure: at most one event item is buffered between polls, and the VM does not produce items while the consumer is not polling. `stream::emit` validates only the configured per-item value bound (payload bytes and nesting depth); sequence assignment, receipts, persistence, and delivery policy belong to the embedding. At most one invocation is active per VM, `Invocation::cancel(reason)` cancels with a typed `OperationCancelReason`, dropping the handle retires the invocation synchronously for immediate VM reuse, and the low-level `Vm::run` pump is unchanged for custom drivers. VM reset uses the generic execution-scope close boundary; a pending close keeps the old scope installed and blocks reuse until `poll_reset_for_reuse` reports quiescence. +## Callable-driven HTTP streams + +With the `http-client` feature, `http::client::request(request)` and `http::client::sse(request, on_event)` are script-facing host imports. SSE is a long-running ordinary host call. Its handler has the schema `fn(SseEvent) -> SseCallbackAction`. The host produces one typed `SseEvent`, the VM runs one child callback frame, and the returned action controls continuation before another event can arrive at the VM boundary. + +The callback may yield or wait in an ordinary async host call. Existing frame machinery resumes the callback first and returns its final action to the suspended HTTP call. The network future does not own or enter the VM and is not polled while the callback is active, so at most one item remains unacknowledged and callback completion supplies backpressure. + +`HttpRequest` and `SseRequest` use ordered `HttpRequestHeader` arrays and the +discriminated `HttpRequestBody` (`text` or `bytes`). Request arrays retain +repeated entries and their supplied order; HTTP does not promise +server-visible ordering across different names. Responses and SSE summaries +use `HttpResponseHeader` arrays in deterministic normalized-name order, with +stable same-name duplicate order and raw `HttpHeaderValue` (`text` or +`bytes`) variants. `SseEvent` exposes named fields for `open`, `event`, and +`end` items. See [HTTP client callable contract](http-client.md) +for field-level examples and lifecycle details. + ## Optimized backends Whole-program AOT and Trace JIT use the same builtin call path (static catalog IDs) for environment binding, native frame dispatch for `callvalue`, and prototype-direct native dispatch for `callscript`. Script-frame entry and return preserve frame-relative locals and typed continuations. ## Embedded runtime -`pd-vm-nostd` decodes the same VMBC v12 callable metadata and executes callable binding, `callvalue`, `callscript`, recursive frames, captures, and direct host targets using `core` plus `alloc`, dispatching on the identical static builtin IDs via its checked-in generated mirror. +`pd-vm-nostd` decodes the same VMBC v13 callable metadata and executes callable binding, `callvalue`, `callscript`, recursive frames, captures, and direct host targets using `core` plus `alloc`, dispatching on the identical static builtin IDs via its checked-in generated mirror. diff --git a/docs/http-client.md b/docs/http-client.md new file mode 100644 index 00000000..4ff33f4b --- /dev/null +++ b/docs/http-client.md @@ -0,0 +1,307 @@ +# Native HTTP client and SSE feature + +The `http-client` Cargo feature enables the buffered HTTP request and callable +SSE host builtins on supported native targets. The feature name remains valid +on every target so workspace feature selection stays uniform, but the native +transport implementation is target-gated. + +## Target boundary + +The transport is compiled when both conditions hold: + +- the `http-client` feature is enabled; and +- the target is not in Rust's `wasm` target family (`not(target_family = "wasm")`). + +On `wasm32-unknown-unknown` and other wasm-family targets, enabling +`http-client` is intentionally a no-op for transport publication. Cargo does +not build the native Tokio networking, Hyper, Rustls, URL, or HTTP-body +transport dependencies. The HTTP module, `HttpConfig`/`HttpExtension`/ +`HttpHostExt` exports, HTTP builtins and callables, HTTP catalog functions, and +LSP standard-catalog entries are absent. Browser or other wasm networking must +be supplied by the embedding host instead. + +The build script uses the same target-family boundary when it selects host +source files for generated dispatch and catalog metadata. This keeps the +compiled runtime surface and generated metadata synchronized. + +## Native API + +On a supported native target, enabling `http-client` preserves the public API: + +- `HttpConfig` controls request and stream limits, redirects, timeouts, and + capability policy; +- `HttpExtension` and `HttpHostExt` install the native HTTP host integration; +- `register_http_builtin_module` and `http_host_catalog` expose the native + resource schema and callable metadata; +- `http::client::request` returns a bounded `HttpResponse`; and +- `http::client::sse` drives a bounded SSE stream through a script callback. + +The HTTP and SSE behavior, resource lifecycle, cancellation, and native async +bridge contracts are unchanged by the wasm boundary. See +[`callable-runtime.md`](callable-runtime.md) for the general callable and +host-runtime contract. + +```rust +use http; + +let response = http::client::request({ + method: "POST", + url: "https://example.test/v1/messages", + headers: [ + { name: "content-type", value: "application/json" }, + ], + body: { kind: "text", text: "{}" }, +}); +let status = response.status; +let body = response.body; +``` + +`http::client::request` accepts an `HttpRequest` object with: + +- `method`: one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, or `OPTIONS`; +- `url`: an `http` or `https` URL admitted by host policy; +- `headers`: an optional ordered array of `{ name: string, value: string }` `HttpRequestHeader` values; repeated entries and their supplied order are retained by the runtime, while HTTP does not promise server-visible ordering across different names; +- `body`: an optional `HttpRequestBody`, either `{ kind: "text", text: string }` or `{ kind: "bytes", bytes: bytes }`. + +The response is an `HttpResponse` with field access: + +```rust +response.status // int +response.headers // array +response.body // bytes +response.url // string, the final validated URL after redirects +``` + +Each response header has a `name` and an `HttpHeaderValue`: + +```rust +let header = response.headers[0]; +header.name // string +header.value.kind // "text" or "bytes" +header.value.text // string or null +header.value.bytes // bytes or null +``` + +Response header arrays use deterministic canonical order by normalized header name. Repeated names remain separate entries, and stable sorting preserves the `HeaderMap`-provided order among duplicate values. The original cross-name wire order is not an API contract. Non-UTF-8 values use the `bytes` variant without loss. Use field access on +named values; unknown fields and string indexes are rejected. The request body, +response body, response head, redirect count, concurrent connection count, +connect phase, and total request duration are bounded. `Host`, +`Content-Length`, `Transfer-Encoding`, and `Connection` are client-managed +request headers. A limit, policy, transport, TLS, redirect, or timeout failure +is a host error and produces no response. + +## Server-sent events + +`http::client::sse` is available with the `http-client` feature. + +```rust +fn on_sse(item: SseEvent) -> SseCallbackAction { + if item.kind == "event" { + print(item.data); + } + { action: "continue" } +} + +let result = http::client::sse({ + method: "GET", + url: "https://example.test/events", + headers: [ + { name: "accept", value: "text/event-stream" }, + ], +}, on_sse); +let outcome = result.outcome; +``` + +`http::client::sse(request, on_event)` accepts an `SseRequest` object with the buffered request fields plus optional `timeout_ms`: + +| Field | Required | Accepted type and value | Bound or policy | +| --- | --- | --- | --- | +| `method` | yes | string: `GET` or `POST` | Other methods are rejected before transport admission | +| `url` | yes | string containing an `http` or `https` URL | Protocol family and the configured scheme, host, port, and address policy must all admit it | +| `headers` | no | ordered array of `{ name: string, value: string }` `HttpRequestHeader` values; repeated entries and supplied order are retained | HTTP does not promise server-visible ordering across different names; names and values must be syntactically valid, client-managed request headers remain forbidden, and `Accept: text/event-stream` is supplied when absent | +| `body` | no | `HttpRequestBody`: `{ kind: "text", text: string }` or `{ kind: "bytes", bytes: bytes }`, including for `POST` | Bounded by `max_request_body_bytes` | +| `timeout_ms` | no | positive integer milliseconds | Caps this optional shortening deadline by `HttpConfig::max_stream_duration` | + +The callback schema is `fn(SseEvent) -> SseCallbackAction`. `SseEvent` is a +named tagged record with `kind` equal to `open`, `event`, or `end`. The response +must have an event-stream content type. The response head remains bounded by +the existing HTTP parser. Request headers retain their configured count and byte +budgets. + +The callback receives exactly one `SseEvent` at a time, in this order: + +```rust +// The response was accepted; this precedes every event. +{ + kind: "open", + status: 200, + headers: [HttpResponseHeader, ...], + url: string, + event: null, + data: null, + id: null, + retry_ms: null, +} + +// One parsed event. "event" is per-dispatch state, reset to null at every +// dispatch boundary; "id" and "retry_ms" retain the last valid values. +{ + kind: "event", + status: null, + headers: null, + url: null, + event: string | null, + data: string, + id: string | null, + retry_ms: int | null, +} + +// Clean EOF, after every preceding event callback completed. +{ + kind: "end", + status: null, + headers: null, + url: null, + event: null, + data: null, + id: null, + retry_ms: null, +} +``` + +`open.headers` and the summary's `headers` use deterministic canonical order by +normalized header name. Repeated names remain separate entries, and stable +sorting preserves the `HeaderMap`-provided order among duplicate values. Each +entry's `value` is an `HttpHeaderValue` with `kind: "text"` and `text`, or +`kind: "bytes"` and `bytes`. The unused optional payload field is `null`. The +original cross-name wire order is not an API contract; raw non-UTF-8 values are +preserved. + +The callback must return an `SseCallbackAction`: + +```rust +{ action: "continue" } +{ action: "stop" } +``` + +`continue` acknowledges the item and permits the next network poll. `stop` ends the call locally. Any other shape or action is a callback error. + +SSE parsing follows the event-stream grammar: + +- UTF-8 text may start with one byte-order mark; +- `\r\n`, `\r`, and `\n` line endings are recognized; +- repeated `data:` fields are joined with `\n`, with the final join newline removed at dispatch; +- `event`, `id`, and decimal non-negative `retry` fields are normalized into the typed event record; +- comments and unknown fields are ignored; +- a blank line dispatches only after at least one `data:` field; +- malformed UTF-8, an over-limit line or event, and cumulative received event-stream application bytes exceeding the call limit are host errors. + +There is no automatic reconnection. Values such as a provider's `[DONE]` marker remain ordinary event data. + +## Terminal summaries and errors + +After callback processing terminates normally, the SSE call returns an `SseSummary`: + +```rust +result.outcome // "eof" | "stopped" +result.status // int +result.headers // array +result.url // string +result.items // int +result.bytes_received // int +result.bytes_sent // int +``` + +`items` counts delivered callback items. `bytes_received` and `bytes_sent` are observational summary counters. Limit enforcement uses independent entire-call accounting and does not depend on whether or how these counters are displayed. + +Transport, parser, destination-policy, timeout, and callback failures stay errors. They are never converted into a successful terminal summary. + +## Sequencing, backpressure, and lifecycle + +Streaming is a single caller-owned operation: + +1. the host polls for one protocol item; +2. the VM invokes `on_event` in a child script frame; +3. the callback returns one action; +4. the host applies that action before polling for another item. + +At most one unacknowledged protocol item crosses the host/VM boundary. Decoder scratch space is bounded separately. The network future is not polled while the callback runs, yields, or waits in another async host call. If the callback yields or invokes an ordinary async host function, the callback resumes first; only its final action resumes the outer stream operation. This sequencing supplies backpressure without a background reader or callback queue. + +The network future never owns or re-enters the VM. Callback error, protocol completion, configured deadline, VM reset/shutdown/drop, invocation termination, or normal return retires the operation exactly once. The embedding owns pending futures: retiring a call drops its transport and permit, and a late completion cannot re-enter the VM. + +`request_timeout` is the total bound for a buffered request and does not apply to SSE. `max_stream_duration` is the host-controlled absolute total-duration bound for each SSE call. SSE computes one admission-time deadline from the smaller of `max_stream_duration` and optional positive `timeout_ms`; the script value can only shorten the call and cannot disable or extend the host maximum. DNS, TCP, TLS, active reads, callback execution, and callback waits all count against the same deadline. Embedding invocation retirement may terminate the call sooner. `stream_idle_timeout` remains a separate wait-for-network-progress bound and resets only after progress; periodic traffic cannot extend the total deadline. Network idle time excludes time spent inside the callback, while callback work remains inside the total deadline. + +## Configuration defaults + +`HttpConfig` uses explicit bounded defaults. Streaming byte limits and all timeout fields must remain positive: + +| Field | Default | Purpose | +| --- | ---: | --- | +| `allowed_schemes` | `https` | Scheme allowlist; protocol-family checks still apply | +| `allowed_hosts` | empty | Destination host allowlist; empty denies every host | +| `allowed_ports` | empty | Destination port allowlist; empty denies every port | +| `allow_private_ips` | `false` | Reject private and other special-use addresses | +| `max_redirects` | 5 | Buffered/SSE redirect bound | +| `max_request_body_bytes` | 1 MiB | Request body bound | +| `max_response_body_bytes` | 8 MiB | Buffered response body bound | +| `connect_timeout` | 10 s | DNS/connect/TLS phase bound | +| `request_timeout` | 30 s | Buffered request total duration | +| `max_stream_item_bytes` | 1 MiB | SSE event bound | +| `max_stream_total_bytes` | 64 MiB | Entire-call cumulative received event-stream byte bound | +| `max_sse_line_bytes` | 64 KiB | SSE line bound | +| `max_stream_duration` | 5 min | Host maximum total duration for SSE calls | +| `stream_idle_timeout` | 30 s | Wait-for-network-data bound | + +The shared in-flight connection default is 64. Zero values for streaming byte limits or any timeout are invalid configuration; buffered `max_request_body_bytes` and `max_response_body_bytes` may be zero to prohibit request or response payload bytes. `HttpConfig::default()` allows `https`. Embeddings should set explicit host and port allowlists and add `http` only when cleartext transport is required. Buffered HTTP and SSE accept only `http`/`https`. + +## Destination policy and protocol transports + +Every protocol uses the same admission, address-pinning, and security policy: + +- URLs require a host and reject userinfo; +- both the protocol's scheme family and the configured scheme allowlist must admit the URL; +- host and effective port must match their configured allowlists; +- every DNS result is validated, and the selected validated address is pinned for the connection; +- when private addresses are disabled, private, loopback, link-local, multicast, unspecified, documentation, transition, reserved, and other special-use IPv4/IPv6 ranges are rejected; IPv4-mapped IPv6 addresses receive the IPv4 checks; +- the original validated hostname remains the TLS SNI name and HTTP `Host` authority when connecting to a pinned address; +- buffered HTTP and SSE revalidate every redirect and remove `Authorization` and `Cookie` on a cross-origin redirect; +- ambient proxy settings are ignored. There is no implicit cookie jar, authentication source, or global proxy state. + +The policy snapshot taken at call admission applies for the complete operation. + +Buffered HTTP and SSE use direct Hyper HTTP/1 over Tokio/Rustls connections and perform no independent DNS lookup outside the shared admission and pinning path. + +## Deliberately absent APIs and semantics + +RustScript core provides no script-visible HTTP request ID, response/stream handle, `next`, `next_event`, or `cancel` callable. Streams cannot detach from their caller. There is no multiplexing, background reader, automatic reconnect, provider/model interpretation, agent loop, or platform retry policy. Applications implement provider-specific JSON, `[DONE]`, tool-call deltas, retry rules, and reconnect decisions in RSS or downstream hosts. + +## Cancellation migration + +PR #13 introduced HTTP-private pending-operation and abort-handle maps, one abort pair per request, HTTP owner routes, request-local runtimes, and HTTP-synthesized cancellation errors. The callable streaming contract supersedes those mechanisms. Buffered requests and SSE submit ordinary futures through the embedding-owned async bridge; HTTP has no private pending map, abort map, operation-ID namespace, token owner route, or cancellation state machine. + +The generic `src/builtins/runtime/cancellation.rs` remains for non-HTTP runtime callers. HTTP does not depend on `CancellationToken`, `CancellationReason`, `OperationOwner::Http`, or owner-wide cancellation routing. Embedding-owned retirement of a pending future remains VM lifecycle control and rejects late completion; dropping an `Invocation` also retires active producer/callback waits and returns the VM and connection permit for reuse. This lifecycle cleanup is not an HTTP API-level cancellation facility. + +## Target and backend notes + +The callable pump follows the ordinary host-call suspension boundary for interpreter, Trace JIT, and whole-program AOT execution. Network futures remain outside VM execution, and callback frames use the same wait/yield continuation rules across backends. + +`pd-vm-nostd` retains callable metadata and static builtin IDs without including HTTP transport implementations. WebAssembly and other embeddings can expose host imports only when that embedding supplies the capability, policy configuration, and async driving required by this contract. The contract does not imply an HTTP backend on targets where the host has not provided one. + +## Feature checks + +For a native HTTP build: + +```bash +cargo test -p pd-vm --no-default-features --features runtime,http-client --test http_feature_gating_tests +``` + +For the wasm gating check, keep the feature enabled while selecting a wasm +package or target: + +```bash +cargo check -p pd-vm --no-default-features --features runtime,http-client \ + --target wasm32-unknown-unknown +``` + +This verifies that feature selection is accepted without publishing the native +transport surface. diff --git a/docs/sqlite.md b/docs/sqlite.md new file mode 100644 index 00000000..014ef013 --- /dev/null +++ b/docs/sqlite.md @@ -0,0 +1,216 @@ +# SQLite host API + +RustScript exposes bounded SQLite host functions when the `sqlite` feature is enabled. The +public catalog uses named structs for options, parameters, rows, cells, and results. Connection +arguments are borrowed for operations and consumed by `sqlite::close`. + +## Imports + +- `sqlite::open` +- `sqlite::execute` +- `sqlite::query` +- `sqlite::transaction` +- `sqlite::close` + +The embedding policy controls the allowed database root, unsafe-SQL capability, and host +ceilings. Configure that policy before opening a connection. Each operation is asynchronous; +the VM resumes after the host operation completes. + +## Compiler and editor catalog boundary + +The typed SQLite catalog is a schema-only surface and does not construct a VM or link +`rusqlite`. `sqlite_host_catalog` and the SQLite entries in `standard_host_catalog` remain +available whenever the `runtime` feature is compiled, including builds without the `sqlite` +feature. Catalog-aware compiler callers and the LSP use these declarations for named-struct +field access and exact host signatures. + +The `sqlite` feature controls the executable SQLite module, generated SQLite namespace and +callables, the `rusqlite` dependency, and SQLite registration exports. A runtime build without +that feature can inspect the editor/compiler contract but has no SQLite implementation to bind; +execution requires a build with `sqlite` enabled and the SQLite module registered. + +## Open options (`SqliteOpenOptions`) + +```rust +use sqlite; + +let db = sqlite::open({ + path: ":memory:", + mode: "memory", + limits: { max_rows: 100 }, +}); +``` + +All fields are optional in object literals. A missing field or `null` keeps the host default, +except that `path` remains required at runtime for a usable connection. + +| Field | Type | Runtime behavior | +| --- | --- | --- | +| `path` | optional string | Required and non-empty; resolved under the embedding policy root | +| `mode` | optional string | `memory`, `read_only`, `read_write`, or `read_write_create`; the last is the default | +| `root` | optional string | Must match the embedding policy root when supplied | +| `limits` | optional `SqliteLimits` | Per-connection ceilings; omitted fields keep the host ceiling | + +Unknown fields are rejected by the named-struct compiler contract. + +## Limits (`SqliteLimits`) + +Every limit is an optional int. Omitted or `null` fields keep the embedding ceiling. Supplied +limits must be positive and cannot exceed that ceiling. + +- `max_connections` +- `max_statements` +- `max_rows` +- `max_columns` +- `max_result_bytes` +- `max_statement_bytes` +- `max_parameters` +- `max_parameter_bytes` +- `max_pending_operations` +- `max_transaction_ms` +- `busy_timeout_ms` + +An empty `{}` is valid. Extra fields are rejected. + +## Values (`SqliteValue`) + +Parameters and result cells use the same tagged named struct: + +```text +SqliteValue { + kind: string, + int_value: optional, + float_value: optional, + text_value: optional, + blob_value: optional, +} +``` + +The five `kind` values and their selected payloads are: + +| `kind` | Required payload | Other payloads | +| --- | --- | --- | +| `"null"` | none | must be `null` or omitted | +| `"int"` | `int_value` | must be `null` or omitted | +| `"float"` | `float_value` | must be `null` or omitted | +| `"text"` | `text_value` | must be `null` or omitted | +| `"blob"` | `blob_value` | must be `null` or omitted | + +For example: + +```rust +let values = [ + { kind: "null" }, + { kind: "int", int_value: 7 }, + { kind: "float", float_value: 1.5 }, + { kind: "text", text_value: "hello" }, + { kind: "blob", blob_value: bytes::from_hex("000102") }, +]; +``` + +The host validates this discriminator at runtime. It rejects an unknown `kind`, a missing +selected payload, multiple non-null payloads, and a selected payload with the wrong type. +`"null"` carries no payload. SQLite TEXT that is not valid UTF-8 is returned as `kind: "blob"` +with its original bytes. + +## Execute (`SqliteExecuteResult`) + +`sqlite::execute` takes an `array` and returns a `SqliteExecuteResult`: + +```rust +let inserted = sqlite::execute( + &db, + "INSERT INTO t (value) VALUES (?1)", + [{ kind: "int", int_value: 7 }], +); +let affected = inserted.rows_affected; +let rowid = inserted.last_insert_rowid; +``` + +| Field | Type | +| --- | --- | +| `rows_affected` | int | +| `last_insert_rowid` | int | + +The parameter count and decoded text/blob byte length are checked against the connection +limits before the operation is scheduled. + +## Query (`SqliteQueryResult` and `SqliteRow`) + +`sqlite::query` takes an `array` and returns a `SqliteQueryResult`: + +```rust +let queried = sqlite::query( + &db, + "SELECT value FROM t ORDER BY rowid", + [], + { max_rows: 32 }, +); + +let first_row = queried.rows[0]; +let first_cell = first_row.cells[0]; +if first_cell.kind == "int" { + let value = first_cell.int_value; +} +``` + +| Field | Type | +| --- | --- | +| `columns` | array of string | +| `rows` | array of `SqliteRow` | +| `truncated` | bool | +| `next_cursor` | optional int | + +Each `SqliteRow` has one field, `cells`, an array of `SqliteValue` in column order. Result +limits are charged from the underlying column names and cell payloads, without counting the +named-struct wrapper fields. `max_rows`, `max_columns`, and `max_result_bytes` can truncate a +query; already accepted rows remain in the result. `next_cursor` is the first-column integer +from the last accepted row, or `null` when no such value was accepted. + +## Transactions (`SqliteStatement` and `SqliteTransactionResult`) + +A transaction receives an array of named `SqliteStatement` values. Each statement has a required +`sql` string and optional typed `params`, `query`, and `limits` fields: + +```rust +let results = sqlite::transaction(&db, [ + { + sql: "INSERT INTO t (value) VALUES (?1)", + params: [{ kind: "int", int_value: 8 }], + }, + { + sql: "SELECT value FROM t ORDER BY rowid", + query: true, + limits: { max_rows: 8 }, + }, +]); +``` + +| Field | Type | Runtime behavior | +| --- | --- | --- | +| `sql` | string | Required non-empty SQL | +| `params` | optional `array` | Omitted or `null` means no parameters | +| `query` | optional bool | Omitted or `null` means execute; `true` returns a query result | +| `limits` | optional `SqliteLimits` | Omitted or `null` keeps the connection ceiling | + +The return value is an `array` in statement order: + +```text +SqliteTransactionResult { + kind: string, + execute: optional, + query: optional, +} +``` + +An execute statement produces `{ kind: "execute", execute: ... }`; a query statement produces +`{ kind: "query", query: ... }`. The unselected envelope field is `null`. Discriminate with +`kind` before using `execute` or `query`. The transaction remains atomic: statement order, +rollback on failure, cancellation, deadlines, and result limits are preserved. + +## Resource lifecycle + +`sqlite::close(db)` consumes the connection, cancels pending operations on it, and waits for +worker cleanup through the VM execution scope. VM reset closes remaining connections and retires +pending SQLite operations. Handles are VM-local and generation-checked, so a closed or foreign +handle cannot be reused. diff --git a/examples/sqlite_named_structs.rss b/examples/sqlite_named_structs.rss new file mode 100644 index 00000000..541425fc --- /dev/null +++ b/examples/sqlite_named_structs.rss @@ -0,0 +1,42 @@ +use sqlite; +use bytes; + +fn main() { + let db = sqlite::open({ + path: ":memory:", + mode: "memory", + limits: { max_rows: 32 }, + }); + + sqlite::execute(&db, "CREATE TABLE t (value, payload)", []); + let blob_payload = bytes::from_hex("000102"); + sqlite::execute(&db, "INSERT INTO t VALUES (?1, ?2)", [ + { kind: "int", int_value: 7 }, + { kind: "blob", blob_value: blob_payload }, + ]); + + let queried = sqlite::query(&db, "SELECT value, payload FROM t", [], {}); + let first = queried.rows[0].cells[0]; + assert(first.kind == "int"); + assert(first.int_value == 7); + + let results = sqlite::transaction(&db, [ + { + sql: "INSERT INTO t VALUES (?1, ?2)", + params: [ + { kind: "text", text_value: "hello" }, + { kind: "null" }, + ], + }, + { + sql: "SELECT value, payload FROM t ORDER BY rowid", + query: true, + limits: { max_rows: 32 }, + }, + ]); + assert(results[0].kind == "execute"); + assert(results[1].kind == "query"); + assert(results[1].query.rows[0].cells[0].int_value == 7); + + sqlite::close(db); +} diff --git a/pd-vm-nostd/README.md b/pd-vm-nostd/README.md index 5361e776..b9d52466 100644 --- a/pd-vm-nostd/README.md +++ b/pd-vm-nostd/README.md @@ -6,7 +6,7 @@ compiler, parser, CLI, debugger, JIT/AOT backends, filesystem support, and opera ## Runtime surface -- VMBC v12 decoding with environment-free `CallScript` direct script calls alongside dynamic callable calls +- VMBC v13 decoding with environment-free `CallScript` direct script calls alongside dynamic callable calls, plus an explicit guest named-struct declaration section - stack, local, and recursive script-frame execution for direct bytecode opcodes - instruction fuel with pause/resume support - synchronous named host bindings and dynamic host dispatch diff --git a/pd-vm-nostd/src/vmbc.rs b/pd-vm-nostd/src/vmbc.rs index 93c607f9..1e825c16 100644 --- a/pd-vm-nostd/src/vmbc.rs +++ b/pd-vm-nostd/src/vmbc.rs @@ -10,6 +10,7 @@ use super::{ const MAGIC: [u8; 4] = *b"VMBC"; const VERSION_V11: u16 = 11; const VERSION_V12: u16 = 12; +const VERSION_V13: u16 = 13; const FLAGS: u16 = 0; const MAX_WIRE_PAYLOAD_BYTES: usize = 64 * 1024 * 1024; const MAX_WIRE_BLOB_BYTES: usize = 16 * 1024 * 1024; @@ -74,7 +75,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { let version = cursor.read_u16()?; let has_host_import_schemas = match version { VERSION_V11 => false, - VERSION_V12 => true, + VERSION_V12 | VERSION_V13 => true, _ => return Err(WireError::UnsupportedVersion(version)), }; let flags = cursor.read_u16()?; @@ -123,6 +124,9 @@ pub fn decode_program(bytes: &[u8]) -> Result { root_callable_bindings, exported_callables, ) = read_callable_metadata(&mut cursor)?; + if version >= VERSION_V13 { + skip_named_struct_decls(&mut cursor)?; + } if !cursor.is_empty() { return Err(WireError::TrailingBytes); } @@ -230,6 +234,15 @@ fn skip_host_schema(cursor: &mut Cursor<'_>, depth: usize) -> Result<(), WireErr skip_host_schema(cursor, depth + 1) } 12 => cursor.skip_string(), + 13 => { + cursor.skip_string()?; + let field_count = cursor.read_count_with_overhead("host named struct fields", 1, 1)?; + for _ in 0..field_count { + cursor.skip_string()?; + skip_host_schema(cursor, depth + 1)?; + } + Ok(()) + } value => Err(WireError::InvalidValueType(value)), } } @@ -286,6 +299,31 @@ fn skip_schema(cursor: &mut Cursor<'_>, depth: usize) -> Result<(), WireError> { } } +fn skip_named_struct_decls(cursor: &mut Cursor<'_>) -> Result<(), WireError> { + let count = cursor.read_count("named struct decls", 1)?; + let mut seen_names = Vec::new(); + reserve(&mut seen_names, "named struct decls", count)?; + for _ in 0..count { + let name = cursor.read_string()?; + if seen_names.iter().any(|existing| existing == &name) { + return Err(WireError::InvalidValueType(0)); + } + seen_names.push(name); + let param_count = cursor.read_count("named struct type params", 1)?; + let mut seen = Vec::new(); + reserve(&mut seen, "named struct type params", param_count)?; + for _ in 0..param_count { + let param = cursor.read_string()?; + if seen.iter().any(|existing| existing == ¶m) { + return Err(WireError::InvalidValueType(0)); + } + seen.push(param); + } + skip_schema(cursor, 0)?; + } + Ok(()) +} + fn skip_resource_key(cursor: &mut Cursor<'_>) -> Result<(), WireError> { let bytes = cursor.read_blob("schema resource key")?; if bytes.is_empty() || bytes.len() > MAX_RESOURCE_KEY_LEN { diff --git a/pd-vm-nostd/tests/call_script_tests.rs b/pd-vm-nostd/tests/call_script_tests.rs index 3154a4df..28d7ef35 100644 --- a/pd-vm-nostd/tests/call_script_tests.rs +++ b/pd-vm-nostd/tests/call_script_tests.rs @@ -1,6 +1,6 @@ //! Milestone 7: `CallScript` parity in the no_std + alloc runtime. //! -//! Programs are produced by the std VMBC encoder (V12) or hand-built with +//! Programs are produced by the std VMBC encoder (V13) or hand-built with //! `CallScript` bytecode (0x1A, prototype_id:u32 LE, argc:u8) so the wire //! contract and the typed validation/execution failures are pinned //! independently of the compiler. @@ -70,8 +70,8 @@ fn call_script_executes_direct_call() { let compiled = compile_source("fn add2(value: int) -> int { value + 2 } add2(40);") .expect("direct call source should compile"); let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) - .expect("direct call program should encode as VMBC v12"); - let program = decode_program(&bytes).expect("no-std should decode VMBC v12"); + .expect("direct call program should encode as VMBC v13"); + let program = decode_program(&bytes).expect("no-std should decode VMBC v13"); assert!( program.code().windows(2).any(|pair| pair[0] == 0x1A), "compiler output should contain CallScript" diff --git a/pd-vm-nostd/tests/embedded_vmbc.rs b/pd-vm-nostd/tests/embedded_vmbc.rs index ee8e0c1b..ab6c0c7c 100644 --- a/pd-vm-nostd/tests/embedded_vmbc.rs +++ b/pd-vm-nostd/tests/embedded_vmbc.rs @@ -5,9 +5,9 @@ use pd_vm_nostd::{ use vm::compiler::TypeSchema; use vm::{ HostApiBuilder, HostFunctionSchema, HostImport, HostImportSchema, HostParamPassing, - HostParamSchema, HostTypeSchema, OpCode, Program, ReplLocalBinding, ResourceTypeKey, - ResourceTypeSchema, TypeMap, Value, ValueType, compile_source, compile_source_for_repl, - compile_source_for_repl_with_locals, encode_program, + HostParamSchema, HostStructField, HostStructSchema, HostTypeSchema, OpCode, Program, + ReplLocalBinding, ResourceTypeKey, ResourceTypeSchema, TypeMap, Value, ValueType, + compile_source, compile_source_for_repl, compile_source_for_repl_with_locals, encode_program, }; fn encoded_scalar_program() -> Vec { @@ -31,9 +31,9 @@ fn encoded_scalar_program() -> Vec { } #[test] -fn embedded_decoder_reads_host_generated_v12() { +fn embedded_decoder_reads_host_generated_v13() { let bytes = encoded_scalar_program(); - let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v12"); + let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v13"); assert_eq!( program.code(), @@ -89,6 +89,62 @@ fn embedded_decoder_skips_full_host_schema_metadata() { assert_eq!(decoded.imports().len(), 1); } +fn point_named_schema() -> HostTypeSchema { + HostTypeSchema::named_struct( + "Point", + vec![ + HostStructField::new("x", HostTypeSchema::Int), + HostStructField::new("y", HostTypeSchema::Int), + ], + ) +} + +fn envelope_named_schema() -> HostTypeSchema { + HostTypeSchema::named_struct( + "Envelope", + vec![HostStructField::new("inner", point_named_schema())], + ) +} + +#[test] +fn embedded_decoder_skips_named_host_schema_from_std_encode() { + let function = HostFunctionSchema::with_return( + "embedded::named", + vec![HostParamSchema::value("req", envelope_named_schema())], + point_named_schema(), + ); + let mut builder = HostApiBuilder::new(); + builder.named_struct(HostStructSchema::new( + "Point", + vec![ + HostStructField::new("x", HostTypeSchema::Int), + HostStructField::new("y", HostTypeSchema::Int), + ], + )); + builder.named_struct(HostStructSchema::new( + "Envelope", + vec![HostStructField::new("inner", point_named_schema())], + )); + builder.function(function.clone()); + let catalog = builder.build().expect("catalog"); + let schema = HostImportSchema::from_function(&catalog, &function); + + let mut program = Program::new(Vec::new(), vec![OpCode::Ret as u8]); + program.imports.push(HostImport { + name: "embedded::named".to_string(), + arity: 1, + return_type: ValueType::Map, + }); + let program = program + .with_host_import_schemas(vec![schema]) + .expect("schema alignment"); + let bytes = encode_program(&program).expect("named host schema should encode"); + assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 13); + let decoded = decode_program(&bytes).expect("embedded decoder should skip Named host schemas"); + assert_eq!(decoded.imports().len(), 1); + assert_eq!(decoded.imports()[0].name, "embedded::named"); +} + #[test] fn embedded_decoder_reads_legacy_v11_without_schema_markers() { let program = Program::new( @@ -96,6 +152,8 @@ fn embedded_decoder_reads_legacy_v11_without_schema_markers() { vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Ret as u8], ); let mut bytes = encode_program(&program).expect("legacy fixture should encode"); + assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); + bytes.truncate(bytes.len() - 4); bytes[4..6].copy_from_slice(&11u16.to_le_bytes()); let decoded = decode_program(&bytes).expect("embedded decoder should accept VMBC v11"); @@ -460,3 +518,174 @@ fn call_script_opcode_is_0x1a_in_both_crates() { ); assert!(EmbeddedOpCode::try_from(0x7f).is_err()); } + +fn append_wire_string(out: &mut Vec, value: &str) { + out.extend_from_slice(&(value.len() as u32).to_le_bytes()); + out.extend_from_slice(value.as_bytes()); +} + +fn v12_with_named_host_return_schema(schema: &[u8]) -> Vec { + let mut bytes = minimal_vmbc_prefix(0, &[EmbeddedOpCode::Ret as u8], 1); + bytes.extend_from_slice(&1u32.to_le_bytes()); + bytes.push(b'h'); + bytes.extend_from_slice(&[0, 0, 1]); + bytes.extend_from_slice(&1u32.to_le_bytes()); + bytes.push(b'h'); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(schema); + bytes.extend_from_slice(&0u64.to_le_bytes()); + bytes.push(0); + bytes.push(0); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes +} + +fn empty_named_host_schema(name: &str) -> Vec { + let mut schema = vec![13]; + append_wire_string(&mut schema, name); + schema.extend_from_slice(&0u32.to_le_bytes()); + schema +} + +fn nested_named_host_schema(depth: usize) -> Vec { + let mut schema = empty_named_host_schema("Leaf"); + for _ in 0..depth { + let mut outer = vec![13]; + append_wire_string(&mut outer, "Wrap"); + outer.extend_from_slice(&1u32.to_le_bytes()); + append_wire_string(&mut outer, "inner"); + outer.extend_from_slice(&schema); + schema = outer; + } + schema +} + +#[test] +fn embedded_decoder_accepts_empty_named_host_schema() { + let bytes = v12_with_named_host_return_schema(&empty_named_host_schema("Point")); + decode_program(&bytes).expect("empty Named host schema should skip"); +} + +#[test] +fn embedded_decoder_rejects_truncated_named_host_schema() { + let mut schema = vec![13]; + append_wire_string(&mut schema, "Point"); + schema.extend_from_slice(&1u32.to_le_bytes()); + assert_eq!( + decode_program(&v12_with_named_host_return_schema(&schema)), + Err(WireError::UnexpectedEof) + ); +} + +#[test] +fn embedded_decoder_rejects_oversized_named_host_field_count() { + const TOO_MANY: u32 = 1_000_001; + let mut schema = vec![13]; + append_wire_string(&mut schema, "Point"); + schema.extend_from_slice(&TOO_MANY.to_le_bytes()); + assert!(matches!( + decode_program(&v12_with_named_host_return_schema(&schema)), + Err(WireError::LengthTooLarge("host named struct fields", count)) + if count == TOO_MANY as usize + )); +} + +#[test] +fn embedded_decoder_rejects_malformed_nested_named_host_schema() { + let mut schema = vec![13]; + append_wire_string(&mut schema, "Outer"); + schema.extend_from_slice(&1u32.to_le_bytes()); + append_wire_string(&mut schema, "inner"); + schema.push(99); + assert_eq!( + decode_program(&v12_with_named_host_return_schema(&schema)), + Err(WireError::InvalidValueType(99)) + ); +} + +#[test] +fn embedded_decoder_rejects_truncated_nested_named_host_schema() { + let mut schema = vec![13]; + append_wire_string(&mut schema, "Outer"); + schema.extend_from_slice(&1u32.to_le_bytes()); + append_wire_string(&mut schema, "inner"); + schema.push(13); + append_wire_string(&mut schema, "Inner"); + schema.extend_from_slice(&1u32.to_le_bytes()); + assert_eq!( + decode_program(&v12_with_named_host_return_schema(&schema)), + Err(WireError::UnexpectedEof) + ); +} + +#[test] +fn embedded_decoder_rejects_oversized_nested_named_host_depth() { + let bytes = v12_with_named_host_return_schema(&nested_named_host_schema(64)); + assert_eq!(decode_program(&bytes), Err(WireError::SchemaTooDeep)); +} + +#[test] +fn embedded_decoder_reads_v13_guest_named_struct_payload() { + let compiled = compile_source( + r#" + struct Point { x: int, y: int } + fn ident(p: Point) -> Point { p } + ident({ x: 8, y: 9 }); + "#, + ) + .expect("guest Named source should compile"); + let bytes = encode_program(&compiled.program).expect("struct-bearing program should encode"); + assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 13); + let program = decode_program(&bytes).expect("embedded decoder should skip guest named structs"); + assert_eq!(program.code().last().copied(), Some(OpCode::Ret as u8)); +} + +#[test] +fn embedded_decoder_rejects_duplicate_named_struct_generic_params() { + let mut bytes = encode_program(&Program::new(Vec::new(), vec![OpCode::Ret as u8])) + .expect("empty program should encode"); + assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); + bytes.truncate(bytes.len() - 4); + bytes.extend_from_slice(&1u32.to_le_bytes()); + append_wire_string(&mut bytes, "Holder"); + bytes.extend_from_slice(&2u32.to_le_bytes()); + append_wire_string(&mut bytes, "T"); + append_wire_string(&mut bytes, "T"); + bytes.push(14); + bytes.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(decode_program(&bytes), Err(WireError::InvalidValueType(0))); +} + +#[test] +fn embedded_decoder_rejects_duplicate_named_struct_names() { + let mut bytes = encode_program(&Program::new(Vec::new(), vec![OpCode::Ret as u8])) + .expect("empty program should encode"); + assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); + bytes.truncate(bytes.len() - 4); + bytes.extend_from_slice(&2u32.to_le_bytes()); + append_wire_string(&mut bytes, "Dup"); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.push(14); + bytes.extend_from_slice(&0u32.to_le_bytes()); + append_wire_string(&mut bytes, "Dup"); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.push(14); + bytes.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(decode_program(&bytes), Err(WireError::InvalidValueType(0))); +} + +#[test] +fn embedded_decoder_rejects_v12_trailing_zero_named_struct_garbage() { + let mut bytes = encode_program(&Program::new(Vec::new(), vec![OpCode::Ret as u8])) + .expect("empty program should encode"); + assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); + bytes.truncate(bytes.len() - 4); + bytes[4..6].copy_from_slice(&12u16.to_le_bytes()); + decode_program(&bytes).expect("clean v12 should decode"); + bytes.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(decode_program(&bytes), Err(WireError::TrailingBytes)); +} diff --git a/src/builtins/mod.rs b/src/builtins/mod.rs index 761ab5a2..e911859d 100644 --- a/src/builtins/mod.rs +++ b/src/builtins/mod.rs @@ -5,7 +5,7 @@ mod metadata; #[cfg(feature = "runtime")] pub(crate) mod runtime; -#[cfg(test)] +#[allow(unused_imports)] pub use self::metadata::CallableType; pub use self::metadata::{ CallableDef, CallableParam, CallableParamType, CallableSignature, HostExecution, diff --git a/src/builtins/runtime/http/config.rs b/src/builtins/runtime/http/config.rs new file mode 100644 index 00000000..aa661b66 --- /dev/null +++ b/src/builtins/runtime/http/config.rs @@ -0,0 +1,100 @@ +use std::time::Duration; + +use crate::vm::{VmError, VmResult}; + +/// Bounded network policy for the built-in HTTP client and future streaming adapters. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HttpConfig { + pub allowed_schemes: Vec, + pub allowed_hosts: Vec, + pub allowed_ports: Vec, + pub max_redirects: usize, + pub max_request_body_bytes: usize, + /// Maximum number of caller-supplied request header fields. Client-managed + /// fields such as `Host` are outside this extension surface. + pub max_request_header_count: usize, + /// Maximum serialized size of the caller-supplied request header block. + /// Every field contributes `name + ": " + value + "\\r\\n"`; the final + /// terminating `"\\r\\n"` is included as well. + pub max_request_header_bytes: usize, + pub max_response_body_bytes: usize, + pub connect_timeout: Duration, + pub request_timeout: Duration, + pub allow_private_ips: bool, + pub max_stream_item_bytes: usize, + pub max_stream_total_bytes: usize, + pub max_sse_line_bytes: usize, + pub max_stream_duration: Duration, + pub stream_idle_timeout: Duration, +} + +impl HttpConfig { + /// Validates limits that must remain positive for every streaming adapter. + pub fn validate(&self) -> VmResult<()> { + let positive_limits = [ + ("max_stream_item_bytes", self.max_stream_item_bytes), + ("max_stream_total_bytes", self.max_stream_total_bytes), + ("max_sse_line_bytes", self.max_sse_line_bytes), + ]; + if let Some((name, _)) = positive_limits.iter().find(|(_, value)| *value == 0) { + return Err(VmError::HostError(format!( + "HTTP configuration field '{name}' must be positive" + ))); + } + let positive_header_limits = [ + ("max_request_header_count", self.max_request_header_count), + ("max_request_header_bytes", self.max_request_header_bytes), + ]; + if let Some((name, _)) = positive_header_limits.iter().find(|(_, value)| *value == 0) { + return Err(VmError::HostError(format!( + "HTTP configuration field '{name}' must be positive" + ))); + } + let positive_timeouts = [ + ("connect_timeout", self.connect_timeout), + ("request_timeout", self.request_timeout), + ("max_stream_duration", self.max_stream_duration), + ("stream_idle_timeout", self.stream_idle_timeout), + ]; + if let Some((name, _)) = positive_timeouts + .iter() + .find(|(_, timeout)| timeout.is_zero()) + { + return Err(VmError::HostError(format!( + "HTTP configuration field '{name}' must be positive" + ))); + } + if let Some((name, _)) = positive_timeouts + .iter() + .find(|(_, timeout)| std::time::Instant::now().checked_add(*timeout).is_none()) + { + return Err(VmError::HostError(format!( + "HTTP configuration field '{name}' is too large" + ))); + } + Ok(()) + } +} + +impl Default for HttpConfig { + fn default() -> Self { + Self { + allowed_schemes: vec!["https".to_string()], + allowed_hosts: Vec::new(), + allowed_ports: Vec::new(), + max_redirects: 5, + max_request_body_bytes: 1024 * 1024, + max_request_header_count: 100, + max_request_header_bytes: 64 * 1024, + max_response_body_bytes: 8 * 1024 * 1024, + connect_timeout: Duration::from_secs(10), + request_timeout: Duration::from_secs(30), + allow_private_ips: false, + max_stream_item_bytes: 1024 * 1024, + max_stream_total_bytes: 64 * 1024 * 1024, + max_sse_line_bytes: 64 * 1024, + max_stream_duration: Duration::from_secs(5 * 60), + stream_idle_timeout: Duration::from_secs(30), + } + } +} diff --git a/src/builtins/runtime/http/mod.rs b/src/builtins/runtime/http/mod.rs new file mode 100644 index 00000000..6b957308 --- /dev/null +++ b/src/builtins/runtime/http/mod.rs @@ -0,0 +1,707 @@ +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; + +use pd_host_function::pd_host_function; + +use super::typed::{VmMap, VmMapHandle}; +use super::{borrow_arg, take_arg}; +use crate::HostCallResult; +use crate::host_api::{ + HostApiBuilder, HostApiCatalog, HostFunctionSchema, HostParamPassing, HostParamSchema, + HostStructField, HostStructSchema, HostTypeSchema, ResourceTypeSchema, +}; +use crate::vm::resource::HostResource; +use crate::vm::{CallOutcome, CallReturn, HostFunctionRegistry, Value, Vm, VmError, VmResult}; + +mod config; +pub(super) mod policy; +pub(super) mod request; +pub(super) mod sse; + +pub use config::HttpConfig; +use policy::{ConnectionAdmission, ConnectionPermit}; +pub use request::{HttpRequestResource, HttpResponseResource}; +pub(crate) use sse::SseStreamResource; + +const DEFAULT_MAX_HTTP_IN_FLIGHT: usize = 64; + +/// Persistent, per-VM HTTP module state. +/// +/// Lives outside the invocation execution scope: it is installed through the +/// generic module-state store and deliberately survives +/// [`Vm::reset_for_reuse`] and scope close. The in-flight admission counter is +/// shared (via [`Arc`]) with every live connection permit; the last one to +/// drop decrements it, so it stays authoritative across resets without the +/// core ever counting connections by class. +struct HttpHostState { + config: Option, + admission: ConnectionAdmission, +} + +impl Default for HttpHostState { + fn default() -> Self { + Self { + config: None, + admission: ConnectionAdmission::new(DEFAULT_MAX_HTTP_IN_FLIGHT), + } + } +} + +/// HTTP host configuration owned by the HTTP host implementation. +/// +/// Configuration is persistent module state, *outside* invocation resources: +/// [`configure_http`](Self::configure_http) replaces the policy without +/// touching the execution scope, and the policy survives +/// [`Vm::reset_for_reuse`]. Requests and streams are closed/cancelled by the +/// generic execution-scope lifecycle, never by an HTTP-specific owner/type +/// dispatch. +pub trait HttpHostExt { + fn configure_http(&mut self, config: HttpConfig) -> VmResult<()>; + fn set_http_max_in_flight(&mut self, max_in_flight: usize); + fn http_max_in_flight(&mut self) -> usize; + fn clear_http_configuration(&mut self); + fn http_is_configured(&mut self) -> bool; +} + +impl HttpHostExt for Vm { + fn configure_http(&mut self, config: HttpConfig) -> VmResult<()> { + config.validate()?; + let mut ctx = self.host_context(); + let admission = ctx + .module_state::() + .map(|state| state.admission.clone()) + .unwrap_or_else(|| ConnectionAdmission::new(DEFAULT_MAX_HTTP_IN_FLIGHT)); + ctx.set_module_state(HttpHostState { + config: Some(config), + admission, + }); + Ok(()) + } + + fn set_http_max_in_flight(&mut self, max_in_flight: usize) { + let mut ctx = self.host_context(); + if ctx.module_state::().is_none() { + ctx.set_module_state(HttpHostState::default()); + } + ctx.module_state_mut::() + .expect("HTTP host state was inserted") + .admission + .set_max_in_flight(max_in_flight); + } + + fn http_max_in_flight(&mut self) -> usize { + self.host_context() + .module_state::() + .map_or(DEFAULT_MAX_HTTP_IN_FLIGHT, |state| { + state.admission.max_in_flight() + }) + } + + fn clear_http_configuration(&mut self) { + let mut ctx = self.host_context(); + if let Some(state) = ctx.module_state_mut::() { + state.config = None; + } + } + + fn http_is_configured(&mut self) -> bool { + self.host_context() + .module_state::() + .and_then(|state| state.config.as_ref()) + .is_some() + } +} + +/// Captured HTTP configuration plus a connection permit, used to open a +/// request/stream without re-entering the VM. +pub(super) struct HttpRequestContext { + pub(super) config: HttpConfig, + permit: ConnectionPermit, +} + +impl HttpRequestContext { + /// Captures the persistent HTTP policy plus a shared in-flight permit for + /// one connection-oriented adapter. + /// + /// The deadline is validated *before* the permit is acquired, preserving + /// the historical ordering guarantee (a script timeout that cannot form a + /// deadline is rejected even when the in-flight capacity is exhausted). + fn capture( + vm: &mut Vm, + script_timeout: Option, + protocol: &str, + ) -> VmResult<(Self, Instant)> { + let ctx = vm.host_context(); + let state = ctx + .module_state::() + .ok_or_else(|| VmError::HostError("HTTP host is not configured".to_string()))?; + let config = state + .config + .clone() + .ok_or_else(|| VmError::HostError("HTTP host is not configured".to_string()))?; + let admitted_at = Instant::now(); + if script_timeout.is_some_and(|timeout| admitted_at.checked_add(timeout).is_none()) { + return Err(VmError::HostError(format!( + "{protocol} timeout_ms cannot form a deadline" + ))); + } + let duration = script_timeout.map_or(config.max_stream_duration, |timeout| { + timeout.min(config.max_stream_duration) + }); + let deadline = admitted_at.checked_add(duration).ok_or_else(|| { + VmError::HostError("HTTP max_stream_duration cannot form a deadline".to_string()) + })?; + let permit = state.admission.acquire()?; + Ok((Self { config, permit }, deadline)) + } + + /// Consumes the captured permit, transferring it to the caller (e.g. the + /// SSE driver that releases it when the stream finishes). + fn into_permit(self) -> ConnectionPermit { + self.permit + } +} + +/// The shared [`HostApiCatalog`] describing every HTTP host function. +/// +/// The compiler and the runtime registry consume this same catalog, so the +/// fingerprints embedded in compiled `HostImport`s match the schemas +/// registered by [`HttpExtension`] byte-for-byte. +pub fn http_host_catalog() -> Arc { + Arc::clone(HTTP_HOST_CATALOG.get_or_init(build_http_host_catalog)) +} + +static HTTP_HOST_CATALOG: OnceLock> = OnceLock::new(); + +fn build_http_host_catalog() -> Arc { + let request_key = HttpRequestResource::resource_type_key() + .expect("http.request resource type key must be valid"); + let response_key = HttpResponseResource::resource_type_key() + .expect("http.response resource type key must be valid"); + let sse_key = + SseStreamResource::resource_type_key().expect("http.sse resource type key must be valid"); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new( + request_key.clone(), + "An in-flight HTTP request under the configured network policy", + )); + builder.resource(ResourceTypeSchema::new( + response_key.clone(), + "An open HTTP response body stream", + )); + builder.resource(ResourceTypeSchema::new( + sse_key.clone(), + "An incremental SSE stream reader over an open response body stream", + )); + + let http_request_header = http_request_header_struct(); + let http_header_value = http_header_value_struct(); + let http_response_header = http_response_header_struct(&http_header_value); + let http_request_body = http_request_body_struct(); + let sse_event = sse_event_struct(&http_response_header); + let http_request = http_request_struct(&http_request_header, &http_request_body); + let http_response = http_response_struct(&http_response_header); + let sse_request = sse_request_struct(&http_request_header, &http_request_body); + let sse_callback_action = sse_callback_action_struct(); + let sse_summary = sse_summary_struct(&http_response_header); + builder.named_struct(http_request_header.clone()); + builder.named_struct(http_header_value.clone()); + builder.named_struct(http_response_header.clone()); + builder.named_struct(http_request_body.clone()); + builder.named_struct(sse_event.clone()); + builder.named_struct(http_request.clone()); + builder.named_struct(sse_request.clone()); + builder.named_struct(http_response.clone()); + builder.named_struct(sse_callback_action.clone()); + builder.named_struct(sse_summary.clone()); + + // Public headers, request bodies, and SSE events use named records and + // typed arrays. Runtime values remain map/array carriers for these named + // structs. The discriminator payload invariants are enforced by the HTTP + // runtime adapters before transport or callback execution. + builder.function(HostFunctionSchema::with_return( + "http::client::request", + vec![HostParamSchema::value("request", http_request.as_type())], + http_response.as_type(), + )); + builder.function(HostFunctionSchema::with_return( + "http::client::sse", + vec![ + HostParamSchema::value("request", sse_request.as_type()), + HostParamSchema::with_passing( + "on_event", + HostTypeSchema::Callable { + params: vec![sse_event.as_type()], + result: Box::new(sse_callback_action.as_type()), + }, + HostParamPassing::Value, + ), + ], + sse_summary.as_type(), + )); + + Arc::new(builder.build().expect("http catalog must build")) +} + +fn opt(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Optional(Box::new(inner)) +} + +fn array(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Array(Box::new(inner)) +} + +fn http_request_header_struct() -> HostStructSchema { + HostStructSchema::new( + "HttpRequestHeader", + vec![ + HostStructField::new("name", HostTypeSchema::String), + HostStructField::new("value", HostTypeSchema::String), + ], + ) +} + +fn http_header_value_struct() -> HostStructSchema { + HostStructSchema::new( + "HttpHeaderValue", + vec![ + HostStructField::new("kind", HostTypeSchema::String), + HostStructField::new("text", opt(HostTypeSchema::String)), + HostStructField::new("bytes", opt(HostTypeSchema::Bytes)), + ], + ) +} + +fn http_response_header_struct(header_value: &HostStructSchema) -> HostStructSchema { + HostStructSchema::new( + "HttpResponseHeader", + vec![ + HostStructField::new("name", HostTypeSchema::String), + HostStructField::new("value", header_value.as_type()), + ], + ) +} + +fn http_request_body_struct() -> HostStructSchema { + HostStructSchema::new( + "HttpRequestBody", + vec![ + HostStructField::new("kind", HostTypeSchema::String), + HostStructField::new("text", opt(HostTypeSchema::String)), + HostStructField::new("bytes", opt(HostTypeSchema::Bytes)), + ], + ) +} + +fn sse_event_struct(response_header: &HostStructSchema) -> HostStructSchema { + HostStructSchema::new( + "SseEvent", + vec![ + HostStructField::new("kind", HostTypeSchema::String), + HostStructField::new("status", opt(HostTypeSchema::Int)), + HostStructField::new("headers", opt(array(response_header.as_type()))), + HostStructField::new("url", opt(HostTypeSchema::String)), + HostStructField::new("event", opt(HostTypeSchema::String)), + HostStructField::new("data", opt(HostTypeSchema::String)), + HostStructField::new("id", opt(HostTypeSchema::String)), + HostStructField::new("retry_ms", opt(HostTypeSchema::Int)), + ], + ) +} + +fn http_request_struct( + request_header: &HostStructSchema, + request_body: &HostStructSchema, +) -> HostStructSchema { + HostStructSchema::new( + "HttpRequest", + vec![ + HostStructField::new("method", HostTypeSchema::String), + HostStructField::new("url", HostTypeSchema::String), + HostStructField::new("headers", opt(array(request_header.as_type()))), + HostStructField::new("body", opt(request_body.as_type())), + ], + ) +} + +fn sse_request_struct( + request_header: &HostStructSchema, + request_body: &HostStructSchema, +) -> HostStructSchema { + let mut fields = http_request_struct(request_header, request_body).fields; + fields.push(HostStructField::new("timeout_ms", opt(HostTypeSchema::Int))); + HostStructSchema::new("SseRequest", fields) +} + +fn http_response_struct(response_header: &HostStructSchema) -> HostStructSchema { + HostStructSchema::new( + "HttpResponse", + vec![ + HostStructField::new("status", HostTypeSchema::Int), + HostStructField::new("headers", array(response_header.as_type())), + HostStructField::new("body", HostTypeSchema::Bytes), + HostStructField::new("url", HostTypeSchema::String), + ], + ) +} + +fn sse_callback_action_struct() -> HostStructSchema { + HostStructSchema::new( + "SseCallbackAction", + vec![HostStructField::new("action", HostTypeSchema::String)], + ) +} + +fn sse_summary_struct(response_header: &HostStructSchema) -> HostStructSchema { + HostStructSchema::new( + "SseSummary", + vec![ + HostStructField::new("outcome", HostTypeSchema::String), + HostStructField::new("status", HostTypeSchema::Int), + HostStructField::new("headers", array(response_header.as_type())), + HostStructField::new("url", HostTypeSchema::String), + HostStructField::new("items", HostTypeSchema::Int), + HostStructField::new("bytes_received", HostTypeSchema::Int), + HostStructField::new("bytes_sent", HostTypeSchema::Int), + ], + ) +} + +struct HttpAdapterContract { + name: &'static str, + arity: u8, + adapter: fn(&mut Vm, &[Value]) -> VmResult, + runtime_owned_pending: bool, +} + +const HTTP_ADAPTER_CONTRACTS: &[HttpAdapterContract] = &[ + HttpAdapterContract { + name: "http::client::request", + arity: 1, + adapter: request_adapter, + runtime_owned_pending: true, + }, + HttpAdapterContract { + name: "http::client::sse", + arity: 2, + adapter: sse_adapter, + runtime_owned_pending: true, + }, +]; +/// Registers every HTTP host function into `registry` using the exact +/// catalog schema path and the authoritative [`standard_host_catalog`] +/// snapshot. +/// +/// The standard extensions all register against this single combined +/// snapshot, so a standard combined-catalog compile exact-binds the standard +/// HTTP surface byte-for-byte. Callers that compose their own custom catalog +/// or an HTTP *subcatalog* snapshot must use +/// [`register_http_builtin_module_from_catalog`] instead. +pub fn register_http_builtin_module(registry: &mut HostFunctionRegistry) -> VmResult<()> { + let catalog = crate::builtins::runtime::standard_host_catalog(); + register_http_builtin_module_from_catalog(registry, &catalog) +} + +/// Registers every HTTP host function into `registry` using the exact +/// schema path derived from a caller-supplied, validated [`HostApiCatalog`] +/// snapshot. +/// +/// This is the public register-forwarding API for custom embedders who +/// compile against an HTTP subcatalog (or their own composite) rather than +/// the standard combined snapshot: the schemas are extracted from the +/// supplied `catalog`, so the registered exact fingerprint matches what the +/// matching compile emitted. Every required request/SSE member is preflighted +/// against its adapter contract (including labels, passing modes, resource keys +/// and return schema), and all mutations are published atomically. Missing or +/// incompatible members return a typed +/// [`crate::vm::HostImportBindingError`] before registry state changes. +pub fn register_http_builtin_module_from_catalog( + registry: &mut HostFunctionRegistry, + catalog: &HostApiCatalog, +) -> VmResult<()> { + let contract = http_host_catalog(); + let catalog_fingerprint = catalog.fingerprint(); + let contract_fingerprint = contract.fingerprint(); + let schemas = HTTP_ADAPTER_CONTRACTS + .iter() + .map(|entry| { + crate::vm::host_extension::validate_catalog_import_schemas_with_fingerprints( + catalog, + &contract, + entry.name, + catalog_fingerprint, + contract_fingerprint, + ) + .map(|schemas| (entry, schemas)) + }) + .collect::>>()?; + + registry.transactionally(|staged| { + staged.install_named_struct_schemas( + crate::vm::host_extension::catalog_named_struct_schemas(catalog), + )?; + for (entry, schemas) in &schemas { + for schema in schemas.iter().cloned() { + staged.register_exact_static(entry.name, entry.arity, schema, entry.adapter)?; + } + staged.authorize_registered_builtin_import(entry.name); + if entry.runtime_owned_pending { + staged.mark_exact_runtime_owned_pending(entry.name)?; + } + } + Ok(()) + }) +} + +/// Standard [`HostExtension`] registering HTTP through the exact catalog +/// path and installing the persistent policy module state. +pub struct HttpExtension; + +impl crate::vm::HostExtension for HttpExtension { + fn register(&self, registry: &mut HostFunctionRegistry) -> VmResult<()> { + register_http_builtin_module(registry) + } + + fn install(&self, vm: &mut Vm) { + vm.host_context().set_module_state(HttpHostState::default()); + } +} + +fn request_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + match builtin_http_client_request(vm, args)? { + HostCallResult::Return(value) => Ok(CallOutcome::Return(CallReturn::One(Value::Map( + Arc::new(value), + )))), + HostCallResult::Pending(op_id) => Ok(CallOutcome::Pending(op_id)), + } +} + +fn sse_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + match sse::builtin_http_client_sse(vm, args)? { + HostCallResult::Return(value) => Ok(CallOutcome::Return(CallReturn::One(Value::Map( + Arc::new(value), + )))), + HostCallResult::Pending(op_id) => Ok(CallOutcome::Pending(op_id)), + } +} + +/// Starts an HTTP request under the VM's configured network policy. +/// +/// The request is a named `HttpRequest` record with `method`, `url`, optional +/// `headers` as an array of typed `HttpRequestHeader` wrappers, and optional +/// `body` as a typed `HttpRequestBody` wrapper. `HttpRequestBody` discriminates +/// between `{ kind: "text", text: string }` and `{ kind: "bytes", bytes: bytes }`; +/// the unused payload field is null. The response is a named `HttpResponse` +/// record with `status`, typed `HttpResponseHeader` entries in `headers`, raw +/// response `body` bytes, and the final validated `url`. +#[pd_host_function(name = "http::client::request")] +pub(super) fn builtin_http_client_request( + vm: &mut Vm, + request: VmMapHandle, +) -> VmResult> { + request::perform_buffered_request(vm, request) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::policy::{ + SchemeFamily, is_restricted_ip, validate_resolved_addresses, validate_url, + validate_url_policy, + }; + use super::{HttpConfig, HttpHostExt}; + + #[test] + fn default_http_policy_denies_all_hosts() { + let config = HttpConfig::default(); + assert_eq!(config.allowed_schemes, ["https"]); + assert!(config.allowed_hosts.is_empty()); + assert!(config.allowed_ports.is_empty()); + assert!(!config.allow_private_ips); + config.validate().expect("default bounds should be valid"); + } + + #[test] + fn stream_timeout_validation_precedes_permit_admission() { + let mut vm = crate::vm::Vm::new(crate::vm::Program::new(Vec::new(), Vec::new())); + vm.set_http_max_in_flight(0); + vm.configure_http(HttpConfig::default()) + .expect("default config should be valid"); + + let error = super::HttpRequestContext::capture(&mut vm, Some(Duration::MAX), "SSE") + .err() + .expect("an unrepresentable script timeout should be rejected"); + assert!(error.to_string().contains("timeout_ms"), "{error}"); + assert!( + !error.to_string().contains("in-flight request limit"), + "deadline validation must happen before permit admission: {error}" + ); + } + + #[test] + fn http_scheme_family_rejects_non_http_schemes() { + let config = HttpConfig { + allowed_schemes: vec!["http".into(), "https".into(), "ftp".into()], + allowed_hosts: vec!["example.com".into()], + allowed_ports: vec![80, 443], + ..HttpConfig::default() + }; + let http: url::Url = "https://example.com/".parse().expect("valid URL"); + let ftp: url::Url = "ftp://example.com/".parse().expect("valid URL"); + assert!(validate_url_policy(&config, SchemeFamily::Http, &http).is_ok()); + assert!(validate_url_policy(&config, SchemeFamily::Http, &ftp).is_err()); + } + + #[test] + fn empty_port_allowlist_rejects_explicit_and_default_ports() { + let config = HttpConfig { + allowed_schemes: vec!["https".to_string()], + allowed_hosts: vec!["example.com".to_string()], + ..HttpConfig::default() + }; + let explicit = "https://example.com:443/".parse().expect("valid URL"); + let default_port = "https://example.com/".parse().expect("valid URL"); + assert!(validate_url(&config, SchemeFamily::Http, &explicit).is_err()); + assert!(validate_url(&config, SchemeFamily::Http, &default_port).is_err()); + } + + #[test] + fn pinned_resolution_preserves_the_original_host_and_validated_address() { + let config = HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![8080], + allow_private_ips: true, + ..HttpConfig::default() + }; + let url = "http://127.0.0.1:8080/".parse().expect("valid pinned URL"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + + let target = runtime + .block_on(super::policy::resolve_url( + &config, + SchemeFamily::Http, + &url, + )) + .expect("target should resolve under policy"); + + assert_eq!(target.host, "127.0.0.1"); + assert_eq!(target.address, "127.0.0.1:8080".parse().unwrap()); + } + + #[test] + fn special_use_networks_and_mixed_dns_answers_are_restricted() { + for address in [ + "0.1.2.3", + "100.64.0.1", + "192.0.0.8", + "192.0.2.1", + "192.31.196.1", + "192.52.193.1", + "192.88.99.1", + "192.175.48.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "240.0.0.1", + "100::1", + "2001::1", + "2001:db8::1", + "2002::1", + "2620:4f:8000::1", + "3fff::1", + "fc00::1", + ] { + assert!( + is_restricted_ip(address.parse().expect("valid IP")), + "{address} must be restricted" + ); + } + for address in ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"] { + assert!( + !is_restricted_ip(address.parse().expect("valid IP")), + "{address} must remain globally routable" + ); + } + + let config = HttpConfig::default(); + let addresses = [ + "8.8.8.8:443".parse().expect("valid socket address"), + "100.64.0.1:443".parse().expect("valid socket address"), + ]; + assert!(validate_resolved_addresses(&config, &addresses).is_err()); + } + + #[test] + fn ipv4_mapped_ipv6_loopback_is_restricted() { + assert!(is_restricted_ip( + "::ffff:127.0.0.1".parse().expect("valid IP") + )); + } + + #[test] + fn http_config_persists_across_scope_reset() { + let mut vm = crate::vm::Vm::new(crate::vm::Program::new(Vec::new(), Vec::new())); + vm.configure_http(HttpConfig::default()) + .expect("default config should be valid"); + assert!(vm.http_is_configured()); + + vm.reset_for_reuse() + .expect("reset should complete for an idle VM"); + assert!( + vm.http_is_configured(), + "the persistent HTTP config must survive reset" + ); + + vm.clear_http_configuration(); + assert!(!vm.http_is_configured()); + // A VM that never runs keeps working after config removal. + } +} + +#[cfg(test)] +mod contract_tests { + use super::*; + use crate::bytecode::{HostImport, ValueType}; + + #[test] + fn adapter_contract_covers_catalog_and_every_registered_schema() { + let catalog = http_host_catalog(); + let contract_names: std::collections::BTreeSet<&str> = HTTP_ADAPTER_CONTRACTS + .iter() + .map(|entry| entry.name) + .collect(); + let catalog_names: std::collections::BTreeSet<&str> = catalog + .functions() + .iter() + .map(|function| function.name.as_str()) + .collect(); + assert_eq!(contract_names, catalog_names); + + let mut registry = HostFunctionRegistry::empty(); + register_http_builtin_module_from_catalog(&mut registry, &catalog).expect("register HTTP"); + for entry in HTTP_ADAPTER_CONTRACTS { + let schemas = crate::vm::host_extension::catalog_import_schemas(&catalog, entry.name); + let imports = schemas + .iter() + .map(|schema| HostImport { + name: schema.name.clone(), + arity: schema.arity() as u8, + return_type: ValueType::Map, + }) + .collect::>(); + let schema_slots = schemas.into_iter().map(Some).collect::>(); + assert!( + registry + .prepare_plan_with_schemas(&imports, &schema_slots) + .is_ok(), + "{}", + entry.name + ); + } + } +} diff --git a/src/builtins/runtime/http/policy.rs b/src/builtins/runtime/http/policy.rs new file mode 100644 index 00000000..f94a8434 --- /dev/null +++ b/src/builtins/runtime/http/policy.rs @@ -0,0 +1,299 @@ +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use super::config::HttpConfig; +use crate::vm::{VmError, VmResult}; + +/// URL scheme family admitted by a protocol adapter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum SchemeFamily { + Http, +} + +impl SchemeFamily { + fn accepts(self, scheme: &str) -> bool { + match self { + Self::Http => matches!(scheme, "http" | "https"), + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct ResolvedTarget { + pub(super) host: String, + pub(super) address: SocketAddr, +} + +/// Shared admission state for every connection-oriented HTTP adapter. +#[derive(Clone, Debug)] +pub(super) struct ConnectionAdmission { + max_in_flight: usize, + in_flight: Arc, +} + +impl ConnectionAdmission { + pub(super) fn new(max_in_flight: usize) -> Self { + Self { + max_in_flight, + in_flight: Arc::new(AtomicUsize::new(0)), + } + } + + pub(super) fn set_max_in_flight(&mut self, max_in_flight: usize) { + self.max_in_flight = max_in_flight; + } + + pub(super) fn max_in_flight(&self) -> usize { + self.max_in_flight + } + + pub(super) fn acquire(&self) -> VmResult { + let mut active = self.in_flight.load(Ordering::Acquire); + loop { + if active >= self.max_in_flight { + return Err(VmError::HostError(format!( + "HTTP in-flight request limit of {} was reached", + self.max_in_flight + ))); + } + match self.in_flight.compare_exchange_weak( + active, + active + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => { + return Ok(ConnectionPermit { + in_flight: Arc::clone(&self.in_flight), + }); + } + Err(observed) => active = observed, + } + } + } +} + +/// Releases one shared connection slot when its embedding-owned future retires. +pub(super) struct ConnectionPermit { + in_flight: Arc, +} + +impl Drop for ConnectionPermit { + fn drop(&mut self) { + self.in_flight.fetch_sub(1, Ordering::AcqRel); + } +} + +pub(super) fn validate_url_policy( + config: &HttpConfig, + family: SchemeFamily, + url: &url::Url, +) -> VmResult<(String, u16)> { + validate_url_structure(url)?; + let scheme = url.scheme().to_ascii_lowercase(); + if !family.accepts(&scheme) + || !config + .allowed_schemes + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(&scheme)) + { + return Err(VmError::HostError(format!( + "HTTP URL scheme '{scheme}' is not allowed", + ))); + } + let host = url + .host_str() + .expect("structurally validated HTTP URL must have a host"); + if !config + .allowed_hosts + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(host)) + { + return Err(VmError::HostError( + "HTTP target host is not allowed".to_string(), + )); + } + let port = url + .port_or_known_default() + .ok_or_else(|| VmError::HostError("HTTP URL has no known port".to_string()))?; + if !config.allowed_ports.contains(&port) { + return Err(VmError::HostError(format!( + "HTTP target port {port} is not allowed", + ))); + } + Ok((host.to_string(), port)) +} + +fn validate_url_structure(url: &url::Url) -> VmResult<()> { + if !url.username().is_empty() || url.password().is_some() { + return Err(VmError::HostError( + "HTTP URL userinfo is not allowed".to_string(), + )); + } + url.host_str() + .ok_or_else(|| VmError::HostError("HTTP URL has no host".to_string()))?; + Ok(()) +} + +pub(super) async fn resolve_url( + config: &HttpConfig, + family: SchemeFamily, + url: &url::Url, +) -> VmResult { + let (host, port) = validate_url_policy(config, family, url)?; + let addresses = if let Ok(host_ip) = host.parse::() { + vec![SocketAddr::new(host_ip, port)] + } else { + tokio::net::lookup_host((host.as_str(), port)) + .await + .map_err(|error| VmError::HostError(format!("HTTP host resolution failed: {error}")))? + .collect::>() + }; + validate_resolved_addresses(config, &addresses)?; + let address = addresses + .first() + .copied() + .ok_or_else(|| VmError::HostError("HTTP target resolves to a restricted IP".to_string()))?; + Ok(ResolvedTarget { host, address }) +} + +pub(super) fn validate_resolved_addresses( + config: &HttpConfig, + addresses: &[SocketAddr], +) -> VmResult<()> { + if addresses.is_empty() + || (!config.allow_private_ips + && addresses + .iter() + .any(|address| is_restricted_ip(address.ip()))) + { + return Err(VmError::HostError( + "HTTP target resolves to a restricted IP".to_string(), + )); + } + Ok(()) +} + +pub(super) fn is_restricted_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + matches!(octets[0], 0 | 10 | 127) + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + || (octets[0] == 169 && octets[1] == 254) + || (octets[0] == 172 && (16..=31).contains(&octets[1])) + || (octets[0] == 192 + && matches!( + (octets[1], octets[2]), + (0, 0) | (0, 2) | (31, 196) | (52, 193) | (88, 99) | (168, _) | (175, 48) + )) + || (octets[0] == 198 + && ((18..=19).contains(&octets[1]) || (octets[1] == 51 && octets[2] == 100))) + || (octets[0] == 203 && octets[1] == 0 && octets[2] == 113) + || octets[0] >= 224 + } + IpAddr::V6(ip) => { + if let Some(mapped) = ip.to_ipv4_mapped() { + return is_restricted_ip(IpAddr::V4(mapped)); + } + let segments = ip.segments(); + let outside_global_unicast = segments[0] & 0xe000 != 0x2000; + let protocol_assignments = segments[0] == 0x2001 && segments[1] <= 0x01ff; + let documentation = (segments[0] == 0x2001 && segments[1] == 0x0db8) + || (segments[0] == 0x3fff && segments[1] & 0xf000 == 0); + let six_to_four = segments[0] == 0x2002; + let direct_delegation_as112 = + segments[0] == 0x2620 && segments[1] == 0x004f && segments[2] == 0x8000; + outside_global_unicast + || protocol_assignments + || documentation + || six_to_four + || direct_delegation_as112 + } + } +} + +pub(super) fn phase_deadline(absolute: Instant, phase_limit: std::time::Duration) -> Instant { + phase_deadline_at(Instant::now(), absolute, phase_limit) +} + +fn phase_deadline_at(now: Instant, absolute: Instant, phase_limit: std::time::Duration) -> Instant { + absolute.min(now.checked_add(phase_limit).unwrap_or(absolute)) +} + +pub(super) async fn with_deadline( + deadline: Instant, + future: impl std::future::Future>, +) -> VmResult { + tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), future) + .await + .map_err(|_| VmError::HostError("HTTP request deadline exceeded".to_string()))? +} + +pub(super) fn request_deadline(timeout: std::time::Duration) -> VmResult { + Instant::now().checked_add(timeout).ok_or_else(|| { + VmError::HostError("HTTP request_timeout cannot form a deadline".to_string()) + }) +} + +#[cfg(test)] +pub(super) fn validate_url( + config: &HttpConfig, + family: SchemeFamily, + url: &url::Url, +) -> VmResult> { + let (host, port) = validate_url_policy(config, family, url)?; + if config.allow_private_ips { + return Ok(None); + } + if let Ok(host_ip) = host.parse::() { + validate_resolved_addresses(config, &[SocketAddr::new(host_ip, port)])?; + return Ok(None); + } + use std::net::ToSocketAddrs; + let addresses = (host.as_str(), port) + .to_socket_addrs() + .map_err(|error| VmError::HostError(format!("HTTP host resolution failed: {error}")))? + .collect::>(); + validate_resolved_addresses(config, &addresses)?; + Ok(addresses.first().copied()) +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::phase_deadline_at; + + #[test] + fn opening_phase_deadline_cannot_reset_absolute_budget_per_hop() { + let start = Instant::now(); + let absolute = start + Duration::from_secs(10); + assert_eq!( + phase_deadline_at( + start + Duration::from_secs(1), + absolute, + Duration::from_secs(10) + ), + absolute + ); + assert_eq!( + phase_deadline_at( + start + Duration::from_secs(8), + absolute, + Duration::from_secs(10) + ), + absolute + ); + assert_eq!( + phase_deadline_at( + start + Duration::from_secs(11), + absolute, + Duration::from_secs(10) + ), + absolute + ); + } +} diff --git a/src/builtins/runtime/http/request.rs b/src/builtins/runtime/http/request.rs new file mode 100644 index 00000000..883d43c0 --- /dev/null +++ b/src/builtins/runtime/http/request.rs @@ -0,0 +1,2157 @@ +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; +use std::task::{Context, Poll}; +use std::time::Instant; + +use futures_util::task::AtomicWaker; +use http_body_util::BodyExt; +use hyper::body::Body as _; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::sync::Notify; + +use super::HttpRequestContext; +use super::config::HttpConfig; +use super::policy::{ConnectionPermit, SchemeFamily, request_deadline, resolve_url, with_deadline}; +use crate::HostCallResult; +use crate::builtins::runtime::typed::{VmMap, VmMapHandle}; +use crate::host_api::ResourceTypeKey; +use crate::vm::operation::{ + HostOperation, OperationCancelReason, OperationError, OperationErrorCode, OperationId, + OperationOutcome, OperationResult, OperationSpec, +}; +use crate::vm::resource::{ + CloseProgress, HostResource, ResourceCloseReason, ResourceError, ResourceErrorCode, + ResourceResult, +}; +use crate::vm::{CallReturn, Value, Vm, VmError, VmResult}; + +#[derive(Clone, Default)] +pub(super) struct ResponseReadObserver { + inner: Arc, +} + +#[derive(Default)] +struct ResponseReadMetrics { + phase: AtomicU8, + transport_waker: AtomicWaker, + remaining_body_bytes: AtomicUsize, +} + +impl ResponseReadObserver { + fn mark_final_head(&self) { + self.inner.phase.store(1, Ordering::Release); + } + + pub(super) fn admit_body(&self, limit: usize) { + self.inner + .remaining_body_bytes + .store(limit, Ordering::Release); + self.inner.phase.store(2, Ordering::Release); + self.inner.transport_waker.wake(); + } + + fn body_is_admitted(&self) -> bool { + self.inner.phase.load(Ordering::Acquire) == 2 + } + + fn register_transport_waker(&self, waker: &std::task::Waker) { + self.inner.transport_waker.register(waker); + } + + fn transport_read_limit(&self) -> usize { + if !self.body_is_admitted() { + 1 + } else { + self.inner + .remaining_body_bytes + .load(Ordering::Acquire) + .saturating_add(1) + } + } + + fn body_remaining(&self) -> usize { + self.inner.remaining_body_bytes.load(Ordering::Acquire) + } + + pub(super) fn observe_application_chunk(&self, bytes: usize) { + let _ = self.inner.remaining_body_bytes.fetch_update( + Ordering::AcqRel, + Ordering::Acquire, + |remaining| Some(remaining.saturating_sub(bytes)), + ); + } +} + +// Rustls accepts a 16 KiB TLS fragment plus at most 2 KiB of protocol +// expansion and the five-byte record header. Bounding the adapter below TLS +// makes raw socket reads explicit. Rustls may retain one such record after the +// final HTTP head; ReadCapIo still exposes only remaining application bytes +// plus one overflow sentinel to Hyper. +const TLS_MAX_WIRE_READ: usize = 16_384 + 2_048 + 5; +const HTTP_MAX_HEAD_BYTES: usize = 64 * 1024; + +struct RawReadCapIo { + inner: T, +} + +impl RawReadCapIo { + fn new(inner: T) -> Self { + Self { inner } + } +} + +impl AsyncRead for RawReadCapIo { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + let before = buf.filled().len(); + let mut bounded = buf.take(TLS_MAX_WIRE_READ); + match Pin::new(&mut this.inner).poll_read(cx, &mut bounded) { + Poll::Ready(Ok(())) => { + let read = bounded.filled().len(); + let initialized = bounded.initialized().len(); + unsafe { + buf.assume_init(initialized); + buf.set_filled(before + read); + } + Poll::Ready(Ok(())) + } + other => other, + } + } +} + +impl AsyncWrite for RawReadCapIo { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } + + fn is_write_vectored(&self) -> bool { + self.inner.is_write_vectored() + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[std::io::IoSlice<'_>], + ) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_write_vectored(cx, bufs) + } +} + +struct ReadCapIo { + inner: T, + observer: ResponseReadObserver, + header_suffix: [u8; 4], + header_bytes: usize, + head_total_bytes: usize, + header_complete: bool, + post_body_bytes: usize, + status_prefix: [u8; 12], + status_prefix_len: usize, +} + +impl ReadCapIo { + fn new(inner: T, observer: ResponseReadObserver) -> Self { + Self { + inner, + observer, + header_suffix: [0; 4], + header_bytes: 0, + head_total_bytes: 0, + header_complete: false, + post_body_bytes: 0, + status_prefix: [0; 12], + status_prefix_len: 0, + } + } + + fn observe_head_byte(&mut self, byte: u8) -> std::io::Result<()> { + if self.header_complete { + return Ok(()); + } + if self.status_prefix_len < self.status_prefix.len() { + self.status_prefix[self.status_prefix_len] = byte; + self.status_prefix_len += 1; + } + self.header_suffix.rotate_left(1); + self.header_suffix[3] = byte; + self.head_total_bytes = self.head_total_bytes.checked_add(1).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP response head exceeds limit", + ) + })?; + self.header_bytes = self.header_bytes.checked_add(1).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP response head exceeds limit", + ) + })?; + if self.head_total_bytes > HTTP_MAX_HEAD_BYTES || self.header_bytes > HTTP_MAX_HEAD_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP response head exceeds limit", + )); + } + if self.header_bytes < 4 || self.header_suffix != *b"\r\n\r\n" { + return Ok(()); + } + + let status = std::str::from_utf8(&self.status_prefix[9..12]) + .ok() + .and_then(|digits| digits.parse::().ok()); + if matches!(status, Some(100..=199)) && status != Some(101) { + self.header_suffix = [0; 4]; + self.header_bytes = 0; + self.status_prefix = [0; 12]; + self.status_prefix_len = 0; + } else { + self.header_complete = true; + self.observer.mark_final_head(); + } + Ok(()) + } +} + +impl AsyncRead for ReadCapIo { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + if this.header_complete && !this.observer.body_is_admitted() { + this.observer.register_transport_waker(cx.waker()); + if !this.observer.body_is_admitted() { + return Poll::Pending; + } + } + let before = buf.filled().len(); + let post_body_phase = this.header_complete + && this.observer.body_is_admitted() + && this.observer.body_remaining() == 0; + let read_limit = if post_body_phase { + let remaining = HTTP_MAX_HEAD_BYTES.saturating_sub(this.post_body_bytes); + if remaining == 0 { + return Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP response trailers exceed limit", + ))); + } + remaining.min(1) + } else { + this.observer.transport_read_limit() + }; + let mut bounded = buf.take(read_limit); + match Pin::new(&mut this.inner).poll_read(cx, &mut bounded) { + Poll::Ready(Ok(())) => { + let read = bounded.filled().len(); + let initialized = bounded.initialized().len(); + if post_body_phase { + this.post_body_bytes = + this.post_body_bytes.checked_add(read).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "HTTP response trailers exceed limit", + ) + })?; + } + for byte in &bounded.filled()[..read] { + if let Err(error) = this.observe_head_byte(*byte) { + return Poll::Ready(Err(error)); + } + } + unsafe { + buf.assume_init(initialized); + buf.set_filled(before + read); + } + Poll::Ready(Ok(())) + } + other => other, + } + } +} + +impl AsyncWrite for ReadCapIo { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } + + fn is_write_vectored(&self) -> bool { + self.inner.is_write_vectored() + } + + fn poll_write_vectored( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[std::io::IoSlice<'_>], + ) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_write_vectored(cx, bufs) + } +} + +#[derive(Clone)] +pub(super) struct HttpRequest { + pub(super) method: hyper::Method, + pub(super) url: url::Url, + pub(super) headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>, + pub(super) body: Option>, +} + +/// Serialized request-header admission accounting. +/// +/// The budget describes the caller-controlled header block, not transport +/// headers synthesized by Hyper. A field is counted as +/// `name + ": " + value + "\\r\\n"`, and the block's final `"\\r\\n"` is +/// included by [`RequestHeaderBudget::finish`]. All arithmetic is checked so +/// an oversized input is rejected before `HeaderName`/`HeaderValue` +/// conversion can allocate. +struct RequestHeaderBudget { + max_count: usize, + max_bytes: usize, + count: usize, + bytes: usize, +} + +impl RequestHeaderBudget { + const FIELD_OVERHEAD: usize = 4; // ": " + "\\r\\n" + const BLOCK_TERMINATOR: usize = 2; // "\\r\\n" + + #[cfg(test)] + fn new(max_count: usize, max_bytes: usize) -> Self { + Self { + max_count, + max_bytes, + count: 0, + bytes: 0, + } + } + + fn from_config(config: &HttpConfig) -> Self { + Self { + max_count: config.max_request_header_count, + max_bytes: config.max_request_header_bytes, + count: 0, + bytes: 0, + } + } + + fn admit(&mut self, name: &[u8], value: &[u8]) -> VmResult<()> { + let count = self + .count + .checked_add(1) + .filter(|count| *count <= self.max_count) + .ok_or_else(|| { + VmError::HostError("HTTP request header count exceeds limit".to_string()) + })?; + let field_bytes = name + .len() + .checked_add(value.len()) + .and_then(|bytes| bytes.checked_add(Self::FIELD_OVERHEAD)) + .ok_or_else(|| { + VmError::HostError("HTTP request header bytes exceed limit".to_string()) + })?; + let bytes = self + .bytes + .checked_add(field_bytes) + .filter(|bytes| *bytes <= self.max_bytes) + .ok_or_else(|| { + VmError::HostError("HTTP request header bytes exceed limit".to_string()) + })?; + self.count = count; + self.bytes = bytes; + Ok(()) + } + + fn finish(&mut self) -> VmResult<()> { + self.bytes = self + .bytes + .checked_add(Self::BLOCK_TERMINATOR) + .filter(|bytes| *bytes <= self.max_bytes) + .ok_or_else(|| { + VmError::HostError("HTTP request header bytes exceed limit".to_string()) + })?; + Ok(()) + } + + #[cfg(test)] + fn count(&self) -> usize { + self.count + } + + #[cfg(test)] + fn bytes(&self) -> usize { + self.bytes + } +} + +pub(super) fn validate_request_header_budget( + headers: &[(hyper::header::HeaderName, hyper::header::HeaderValue)], + config: &HttpConfig, +) -> VmResult<()> { + let mut budget = RequestHeaderBudget::from_config(config); + for (name, value) in headers { + budget.admit(name.as_str().as_bytes(), value.as_bytes())?; + } + budget.finish() +} + +pub(super) fn parse_request(map: &VmMap, config: &HttpConfig) -> VmResult { + let method = map_string(map, "method")?.to_ascii_uppercase(); + if !matches!( + method.as_str(), + "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" + ) { + return Err(VmError::HostError(format!( + "HTTP method '{method}' is not allowed" + ))); + } + let method = hyper::Method::from_bytes(method.as_bytes()) + .map_err(|_| VmError::HostError("invalid HTTP method".to_string()))?; + let url = map_string(map, "url")? + .parse::() + .map_err(|error| VmError::HostError(format!("invalid HTTP URL: {error}")))?; + + let body = parse_request_body(map.get(&Value::string("body")), config)?; + + let mut headers = Vec::new(); + let mut header_budget = RequestHeaderBudget::from_config(config); + match map.get(&Value::string("headers")) { + None | Some(Value::Null) => {} + Some(Value::Array(header_entries)) => { + for entry in header_entries.iter() { + let Value::Map(header) = entry else { + return Err(VmError::TypeMismatch("HTTP request header")); + }; + reject_unexpected_fields(header, &["name", "value"], "HTTP request header")?; + let key = required_string_field(header, "name", "HTTP header name")?; + let value = required_string_field(header, "value", "HTTP header value")?; + // Admit raw bytes before normalizing/converting either component. + // This keeps a rejected value from triggering a HeaderValue copy. + header_budget.admit(key.as_bytes(), value.as_bytes())?; + if matches!( + key.to_ascii_lowercase().as_str(), + "host" | "content-length" | "transfer-encoding" | "connection" + ) { + return Err(VmError::HostError(format!( + "HTTP header '{key}' is managed by the client", + ))); + } + let name = hyper::header::HeaderName::from_bytes(key.as_bytes()) + .map_err(|_| VmError::HostError(format!("invalid HTTP header name '{key}'")))?; + let value = hyper::header::HeaderValue::from_str(&value).map_err(|_| { + VmError::HostError(format!("invalid HTTP header value for '{key}'")) + })?; + headers.push((name, value)); + } + } + Some(_) => return Err(VmError::TypeMismatch("HTTP headers")), + } + header_budget.finish()?; + + Ok(HttpRequest { + method, + url, + headers, + body, + }) +} + +fn parse_request_body(value: Option<&Value>, config: &HttpConfig) -> VmResult>> { + let Some(value) = value else { + return Ok(None); + }; + if matches!(value, Value::Null) { + return Ok(None); + } + let Value::Map(body) = value else { + return Err(VmError::TypeMismatch("HTTP request body")); + }; + reject_unexpected_fields(body, &["kind", "text", "bytes"], "HTTP request body")?; + let kind = required_string_field(body, "kind", "HTTP request body kind")?; + let payload = match kind.as_str() { + "text" => { + if body + .get(&Value::string("bytes")) + .is_some_and(|value| !matches!(value, Value::Null)) + { + return Err(VmError::HostError( + "HTTP request body text variant cannot contain bytes".to_string(), + )); + } + let Some(Value::String(text)) = body.get(&Value::string("text")) else { + return Err(VmError::TypeMismatch("HTTP request body text payload")); + }; + text.as_bytes() + } + "bytes" => { + if body + .get(&Value::string("text")) + .is_some_and(|value| !matches!(value, Value::Null)) + { + return Err(VmError::HostError( + "HTTP request body bytes variant cannot contain text".to_string(), + )); + } + let Some(Value::Bytes(bytes)) = body.get(&Value::string("bytes")) else { + return Err(VmError::TypeMismatch("HTTP request body bytes payload")); + }; + bytes.as_ref() + } + _ => { + return Err(VmError::HostError( + "HTTP request body kind must be 'text' or 'bytes'".to_string(), + )); + } + }; + if payload.len() > config.max_request_body_bytes { + return Err(VmError::HostError( + "HTTP request body exceeds limit".to_string(), + )); + } + Ok(Some(payload.to_vec())) +} + +fn reject_unexpected_fields(map: &VmMap, allowed: &[&str], context: &'static str) -> VmResult<()> { + for (key, _) in map { + let Value::String(key) = key else { + return Err(VmError::TypeMismatch(context)); + }; + if !allowed.iter().any(|allowed| *allowed == key.as_str()) { + return Err(VmError::HostError(format!( + "{context} contains unknown field '{key}'" + ))); + } + } + Ok(()) +} + +fn required_string_field(map: &VmMap, key: &str, context: &'static str) -> VmResult { + match map.get(&Value::string(key)) { + Some(Value::String(value)) => Ok(value.as_ref().clone()), + Some(_) => Err(VmError::TypeMismatch(context)), + None => Err(VmError::HostError(format!("{context} is missing '{key}'"))), + } +} + +fn map_string(map: &VmMap, key: &str) -> VmResult { + match map.get(&Value::string(key)) { + Some(Value::String(value)) => Ok(value.as_ref().clone()), + Some(_) => Err(VmError::TypeMismatch("HTTP request string field")), + None => Err(VmError::HostError(format!( + "missing HTTP request field '{key}'" + ))), + } +} + +// --------------------------------------------------------------------------- +// Shared state for the buffered HTTP request lifecycle +// --------------------------------------------------------------------------- + +/// Shared state that coordinates the buffered HTTP request worker thread, +/// the operation poller, and the resource close lifecycle. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WorkerLifecycle { + NotStarted = 0, + Running = 1, + Finished = 2, +} + +struct BufferedRequestShared { + /// Notified on cancel/close so the worker can break out of a blocking + /// network read. Race-free: if notify_one() arrives before the worker + /// starts waiting, the next notified() completes immediately. + cancel: Notify, + /// One-shot result from the worker thread. + result: std::sync::Mutex>>, + /// Set by the worker after publishing `result`. + done: std::sync::atomic::AtomicBool, + /// Set by the spawned closure after the worker entry returns. + thread_finished: std::sync::atomic::AtomicBool, + /// Explicit worker lifecycle. `NotStarted` is also the only state from + /// which workerless rollback may publish a terminal result. + worker_lifecycle: std::sync::atomic::AtomicU8, + /// Waker registered by a pending operation poll. `register` is followed by + /// a result recheck by the operation driver. + waker: AtomicWaker, + /// The worker thread handle, taken during close to join. + join_handle: std::sync::Mutex>>, + /// Waker registered by the close poll when the worker is still running. + close_waker: AtomicWaker, + /// Waker registered by the operation registry while waiting for worker quiescence. + quiescence_waker: AtomicWaker, + /// The connection permit, held until the shared state is dropped (after + /// the worker exits and the resource is closed). + _permit: ConnectionPermit, + /// Set after a rollback has retired both the operation and resource. This + /// makes repeated rollback calls no-ops without touching stale handles. + rollback_finished: std::sync::atomic::AtomicBool, +} + +impl BufferedRequestShared { + fn mark_worker_running(&self) { + let _ = self.worker_lifecycle.compare_exchange( + WorkerLifecycle::NotStarted as u8, + WorkerLifecycle::Running as u8, + Ordering::AcqRel, + Ordering::Acquire, + ); + } + + fn mark_worker_finished(&self) { + self.thread_finished.store(true, Ordering::Release); + self.worker_lifecycle + .store(WorkerLifecycle::Finished as u8, Ordering::Release); + self.close_waker.wake(); + self.quiescence_waker.wake(); + } + + /// Publishes a terminal rollback result for a resource that never had a + /// worker. The compare-exchange prevents this path from claiming a worker + /// which successfully started between admission and rollback. + fn terminalize_workerless(&self, result: VmResult) -> bool { + if self + .worker_lifecycle + .compare_exchange( + WorkerLifecycle::NotStarted as u8, + WorkerLifecycle::Finished as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + { + return false; + } + self.request_stop(); + self.publish(result); + self.thread_finished.store(true, Ordering::Release); + self.close_waker.wake(); + self.quiescence_waker.wake(); + true + } + + fn request_stop(&self) { + self.cancel.notify_one(); + self.waker.wake(); + self.close_waker.wake(); + self.quiescence_waker.wake(); + } + + fn publish(&self, result: VmResult) { + *self + .result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(result); + // The result mutex write happens-before this release publication. + self.done.store(true, Ordering::Release); + self.waker.wake(); + self.close_waker.wake(); + self.quiescence_waker.wake(); + } + + fn has_result(&self) -> bool { + self.result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_some() + } + + fn try_join_finished(&self) -> ResourceResult { + if self.worker_lifecycle.load(Ordering::Acquire) != WorkerLifecycle::Finished as u8 + || !self.done.load(Ordering::Acquire) + || !self.thread_finished.load(Ordering::Acquire) + { + return Ok(false); + } + let handle = { + let mut guard = self + .join_handle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if guard.as_ref().is_some_and(|handle| !handle.is_finished()) { + return Ok(false); + } + guard.take() + }; + let Some(handle) = handle else { + return Ok(true); + }; + handle.join().map(|_| true).map_err(|panic| { + ResourceError::new( + ResourceErrorCode::ResourceCleanupFailed, + "http::request::resource", + worker_panic_message(&panic), + ) + }) + } + + fn is_quiescent(&self) -> bool { + self.worker_lifecycle.load(Ordering::Acquire) == WorkerLifecycle::Finished as u8 + && self.done.load(Ordering::Acquire) + && self.thread_finished.load(Ordering::Acquire) + && self + .join_handle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() + } +} + +fn worker_panic_message(panic: &Box) -> String { + if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = panic.downcast_ref::() { + message.clone() + } else { + "HTTP request worker thread panicked".to_string() + } +} + +#[cfg(test)] +static FAIL_NEXT_WORKER_SPAWN: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[cfg(test)] +static REJECT_NEXT_OPERATION_ADMISSION: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[allow(clippy::result_large_err)] +fn start_operation( + vm: &mut Vm, + operation: T, +) -> crate::vm::host_context::HostContextResult { + #[cfg(test)] + if REJECT_NEXT_OPERATION_ADMISSION.swap(false, Ordering::AcqRel) { + return Err(crate::vm::host_context::HostContextError::new( + "http::operation", + "injected operation admission rejection", + )); + } + vm.host_context() + .start_operation(OperationSpec::new(operation)) +} + +fn spawn_worker(name: &str, function: F) -> std::io::Result> +where + F: FnOnce() + Send + 'static, +{ + #[cfg(test)] + if FAIL_NEXT_WORKER_SPAWN.swap(false, Ordering::AcqRel) { + return Err(std::io::Error::other("injected HTTP worker spawn failure")); + } + std::thread::Builder::new() + .name(name.to_string()) + .spawn(function) +} + +// --------------------------------------------------------------------------- +// Generic scoped host resources and operations +// --------------------------------------------------------------------------- + +/// An HTTP request being processed under the configured network policy. +/// +/// The request resource is registered in the execution scope and associated +/// with the buffered HTTP operation. Its close is the terminal teardown; +/// the scope lifecycle closes the resource (and cancels the operation) on +/// reset/shutdown, ensuring the worker thread is retired. +pub struct HttpRequestResource { + shared: Option>, +} + +impl HttpRequestResource { + fn new(shared: Arc) -> Self { + Self { + shared: Some(shared), + } + } +} + +impl HostResource for HttpRequestResource { + fn resource_type_key() -> Option { + ResourceTypeKey::new("http.request").ok() + } + + fn begin_close(&mut self, reason: ResourceCloseReason) -> ResourceResult { + let _ = reason; + let Some(shared) = self.shared.as_ref() else { + return Ok(CloseProgress::Ready); + }; + // Notify the worker to stop promptly, even if it is blocked on a + // network read. The operation's cancel also does this, but the + // resource close is the authoritative teardown path. + shared.request_stop(); + match shared.try_join_finished()? { + true => Ok(CloseProgress::Ready), + false => Ok(CloseProgress::Pending), + } + } + + fn poll_close(&mut self, cx: &mut Context<'_>) -> Poll> { + let Some(shared) = self.shared.as_ref() else { + return Poll::Ready(Ok(())); + }; + match shared.try_join_finished() { + Ok(true) => Poll::Ready(Ok(())), + Ok(false) => { + shared.close_waker.register(cx.waker()); + match shared.try_join_finished() { + Ok(true) => Poll::Ready(Ok(())), + Ok(false) => Poll::Pending, + Err(error) => Poll::Ready(Err(error)), + } + } + Err(error) => Poll::Ready(Err(error)), + } + } +} + +/// The open HTTP response body stream, used as the parent resource for SSE +/// reader children. +/// +/// Closing it aborts the response stream (the child is closed first by the +/// generic child-first scope shutdown). The SSE reader is registered as a +/// child of this resource so the close order is deterministic: SSE reader +/// first, then the response stream parent. +pub struct HttpResponseResource; + +impl HostResource for HttpResponseResource { + fn resource_type_key() -> Option { + ResourceTypeKey::new("http.response").ok() + } + + fn begin_close(&mut self, reason: ResourceCloseReason) -> ResourceResult { + let _ = reason; + Ok(CloseProgress::Ready) + } +} + +/// Driver for the *buffered* HTTP request operation: runs the request on a +/// worker thread and publishes the response map into a shared cell. +pub(super) struct HttpRequestOperation { + shared: Arc, +} + +impl HttpRequestOperation { + fn new(shared: Arc) -> Self { + Self { shared } + } +} + +impl HostOperation for HttpRequestOperation { + fn poll(&mut self, cx: &mut Context<'_>) -> Poll> { + let result = self + .shared + .result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match result.as_ref() { + Some(Ok(_)) => Poll::Ready(Ok(())), + Some(Err(error)) => Poll::Ready(Err(OperationError::new( + OperationErrorCode::OperationDriverFailed, + "http::client::request", + error.to_string(), + ))), + None => { + drop(result); + self.shared.waker.register(cx.waker()); + let ready = self.shared.has_result(); + if ready { self.poll(cx) } else { Poll::Pending } + } + } + } + + fn is_quiescent(&self) -> bool { + self.shared.is_quiescent() + } + + fn register_quiescence_waker(&mut self, cx: &Context<'_>) { + self.shared.quiescence_waker.register(cx.waker()); + } + + fn poll_quiescent(&mut self, cx: &mut Context<'_>) -> Poll<()> { + if self.shared.is_quiescent() { + return Poll::Ready(()); + } + self.shared.quiescence_waker.register(cx.waker()); + if self.shared.is_quiescent() { + Poll::Ready(()) + } else { + let _ = self.shared.try_join_finished(); + if self.shared.is_quiescent() { + Poll::Ready(()) + } else { + Poll::Pending + } + } + } + + fn cancel(&mut self, reason: OperationCancelReason) -> OperationResult<()> { + let _ = reason; + self.shared.request_stop(); + Ok(()) + } + + fn cancel_and_wait(&mut self, reason: OperationCancelReason) -> OperationResult<()> { + self.cancel(reason)?; + if !self.shared.is_quiescent() { + return Err(OperationError::new( + OperationErrorCode::OperationDriverFailed, + "http::client::request", + "HTTP request worker cancellation is still pending", + )); + } + Ok(()) + } +} + +impl BufferedRequestShared { + /// Cancellation path used when admission has not yet installed an + /// operation id. It synchronously joins the worker so rollback can close + /// and reclaim the resource before returning the primary admission error. + fn cancel_and_join(&self) -> VmResult<()> { + self.request_stop(); + if !self.is_quiescent() { + return Err(VmError::HostError( + "HTTP request worker cancellation is still pending".to_string(), + )); + } + Ok(()) + } +} + +pub(super) fn host_boundary_error(error: crate::vm::HostContextError) -> VmError { + VmError::HostError(error.to_string()) +} + +fn close_buffered_request_resource( + vm: &mut Vm, + handle: crate::vm::resource::ResourceHandle, +) -> VmResult<()> { + match vm + .host_context() + .close_resource::(handle, ResourceCloseReason::Requested) + .map_err(host_boundary_error)? + { + CloseProgress::Ready => Ok(()), + CloseProgress::Pending => Err(VmError::HostError( + "HTTP request resource close remained pending after worker quiescence".to_string(), + )), + } +} + +fn preserve_cleanup_context(primary: VmError, cleanup: Vec) -> VmError { + if cleanup.is_empty() { + return primary; + } + let mut message = primary.to_string(); + for error in cleanup { + use std::fmt::Write as _; + let _ = write!(message, "; cleanup failed: {error}"); + } + VmError::HostError(message) +} + +fn rollback_buffered_request( + vm: &mut Vm, + resource_handle: crate::vm::resource::ResourceHandle, + shared: &Arc, + op_id: Option, + primary: VmError, +) -> VmError { + if shared.rollback_finished.load(Ordering::Acquire) { + return primary; + } + let mut cleanup = Vec::new(); + let _ = shared.terminalize_workerless(Err(VmError::HostError( + "HTTP request worker was not started".to_string(), + ))); + if let Some(op_id) = op_id { + vm.discard_scoped_operation_completion(op_id); + if let Err(error) = vm + .host_context() + .abort_operation(op_id, OperationCancelReason::Requested) + .map(|_| ()) + .map_err(host_boundary_error) + { + cleanup.push(error); + } + } + if let Err(error) = shared.cancel_and_join() { + cleanup.push(error); + } + if let Err(error) = close_buffered_request_resource(vm, resource_handle) { + cleanup.push(error); + } + if cleanup.is_empty() { + shared.rollback_finished.store(true, Ordering::Release); + } + preserve_cleanup_context(primary, cleanup) +} + +// --------------------------------------------------------------------------- +// Buffered request +// --------------------------------------------------------------------------- + +/// Performs one buffered HTTP request as a generic execution-scope operation. +pub(super) fn perform_buffered_request( + vm: &mut Vm, + request: VmMapHandle, +) -> VmResult> { + let (context, _) = HttpRequestContext::capture(vm, None, "HTTP")?; + let config = context.config.clone(); + let permit = context.into_permit(); + let request = parse_request(&request, &config)?; + let deadline = request_deadline(config.request_timeout)?; + + // Shared state that coordinates the worker thread, operation poll, and + // resource close lifecycle. The permit is held here until the shared + // state is dropped (after the worker exits and the resource is closed). + let shared = Arc::new(BufferedRequestShared { + cancel: Notify::new(), + result: std::sync::Mutex::new(None), + done: std::sync::atomic::AtomicBool::new(false), + thread_finished: std::sync::atomic::AtomicBool::new(false), + worker_lifecycle: std::sync::atomic::AtomicU8::new(WorkerLifecycle::NotStarted as u8), + waker: AtomicWaker::new(), + join_handle: std::sync::Mutex::new(None), + close_waker: AtomicWaker::new(), + quiescence_waker: AtomicWaker::new(), + _permit: permit, + rollback_finished: std::sync::atomic::AtomicBool::new(false), + }); + + // Register an HTTP request resource in the scope and associate the + // operation with it. The scope lifecycle closes the resource (and + // cancels the operation) on reset/shutdown. + let request_resource = HttpRequestResource::new(Arc::clone(&shared)); + let resource_token = vm + .host_context() + .push_resource(request_resource) + .map_err(host_boundary_error)?; + let resource_handle = resource_token.handle(); + + // Admit the operation before spawning the worker. Every later handoff + // step can therefore use the operation id for deterministic rollback; a + // failed spawn never leaves a workerless resource/operation pair behind. + let op = HttpRequestOperation::new(Arc::clone(&shared)); + let op_id = match start_operation(vm, op) { + Ok(op_id) => op_id, + Err(error) => { + return Err(rollback_buffered_request( + vm, + resource_handle, + &shared, + None, + host_boundary_error(error), + )); + } + }; + + let pending_result = Arc::clone(&shared); + if let Err(error) = vm.register_scoped_operation_completion(op_id, move |_vm, outcome| { + let result = match outcome { + OperationOutcome::Completed => pending_result + .result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .unwrap_or_else(|| { + Err(VmError::HostError( + "HTTP request produced no result".to_string(), + )) + }), + // Cancellation is an internal teardown path. Resource cleanup is + // still performed below, while callers that explicitly poll a + // cancelled operation receive no guest value. + OperationOutcome::Cancelled(_) => Ok(CallReturn::none()), + OperationOutcome::Failed(error) => Err(VmError::HostError(error.to_string())), + }; + let cleanup = close_buffered_request_resource(_vm, resource_handle); + match (result, cleanup) { + (Ok(values), Ok(())) => Ok(values), + (Err(primary), Ok(())) => Err(primary), + (Ok(_), Err(cleanup)) => Err(cleanup), + (Err(primary), Err(cleanup)) => Err(preserve_cleanup_context(primary, vec![cleanup])), + } + }) { + return Err(rollback_buffered_request( + vm, + resource_handle, + &shared, + Some(op_id), + error, + )); + } + + // Run the request on a worker thread; the operation driver polls the + // shared completion cell. The worker uses tokio::select! to respond + // promptly to cancellation even while blocked on network I/O. + let worker_config = config.clone(); + let worker_request = request.clone(); + let join_handle = match spawn_worker("rustscript-http-request", { + let worker_shared = Arc::clone(&shared); + move || { + let worker_state = Arc::clone(&worker_shared); + worker_state.mark_worker_running(); + let value = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + match runtime_block_on(async { + tokio::select! { + biased; + _ = worker_shared.cancel.notified() => { + Err(VmError::HostError("HTTP request cancelled".to_string())) + } + result = with_deadline( + deadline, + execute_request_until( + &worker_config, + &worker_request, + ResponseReadObserver::default(), + deadline, + None, + ), + ) => { + result.map(|map| CallReturn::one(Value::Map(Arc::new(map)))) + } + } + }) { + Ok(value) => value, + Err(error) => Err(error), + } + })) { + Ok(value) => value, + Err(panic) => Err(VmError::HostError(format!( + "HTTP request worker panicked: {}", + worker_panic_message(&panic) + ))), + }; + worker_shared.publish(value); + worker_state.mark_worker_finished(); + worker_state.waker.wake(); + } + }) { + Ok(join_handle) => join_handle, + Err(error) => { + return Err(rollback_buffered_request( + vm, + resource_handle, + &shared, + Some(op_id), + VmError::HostError(format!("failed to start HTTP worker: {error}")), + )); + } + }; + + shared.mark_worker_running(); + + // Store the join handle so the resource can join it during close. + *shared + .join_handle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(join_handle); + + let raw = op_id.raw(); + Ok(HostCallResult::Pending(raw)) +} + +/// Builds a current-thread tokio runtime to run the blocking HTTP transport. +fn runtime_block_on(future: F) -> VmResult { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| { + VmError::HostError(format!("HTTP worker runtime build failed: {error}")) + })?; + Ok(runtime.block_on(future)) +} + +async fn execute_request_until( + config: &HttpConfig, + request: &HttpRequest, + observer: ResponseReadObserver, + request_deadline: Instant, + tls_config: Option>, +) -> VmResult { + let mut method = request.method.clone(); + let mut url = request.url.clone(); + let mut body = request.body.clone(); + let mut headers = request.headers.clone(); + + for redirect_index in 0..=config.max_redirects { + let connect_deadline = request_deadline.min( + Instant::now() + .checked_add(config.connect_timeout) + .ok_or_else(|| { + VmError::HostError("HTTP connect_timeout cannot form a deadline".to_string()) + })?, + ); + let resolved = with_deadline( + connect_deadline, + resolve_url(config, SchemeFamily::Http, &url), + ) + .await?; + let mut response = send_request( + &method, + &url, + &resolved, + &headers, + body.as_deref(), + ConnectionStage { + observer: observer.clone(), + deadline: connect_deadline, + response_deadline: None, + tls_config: tls_config.clone(), + }, + ) + .await?; + validate_response_framing(response.response())?; + if follows_location(response.response().status()) { + if redirect_index == config.max_redirects { + return Err(VmError::HostError( + "HTTP redirect limit exceeded".to_string(), + )); + } + let location = response + .response() + .headers() + .get(hyper::header::LOCATION) + .ok_or_else(|| VmError::HostError("HTTP redirect has no location".to_string()))? + .to_str() + .map_err(|_| VmError::HostError("HTTP redirect location is invalid".to_string()))? + .to_string(); + let next_url = url + .join(&location) + .map_err(|error| VmError::HostError(format!("invalid HTTP redirect: {error}")))?; + super::policy::validate_url_policy(config, SchemeFamily::Http, &next_url)?; + prepare_redirect( + &url, + &next_url, + response.response().status(), + &mut method, + &mut body, + &mut headers, + ); + url = next_url; + continue; + } + + let status = response.response().status(); + let has_body = response_has_body(&method, status); + if has_body { + reject_declared_oversize(response.response(), config.max_response_body_bytes)?; + } + let response_headers = response_header_entries(response.response().headers()); + if !has_body { + return Ok(response_map(status, response_headers, Vec::new(), &url)); + } + observer.admit_body(config.max_response_body_bytes); + let mut bytes = Vec::with_capacity( + response + .response() + .body() + .size_hint() + .exact() + .and_then(|length| usize::try_from(length).ok()) + .unwrap_or(0) + .min(config.max_response_body_bytes), + ); + while let Some(frame) = response.next_frame().await? { + let Ok(chunk) = frame.into_data() else { + continue; + }; + observer.observe_application_chunk(chunk.len()); + if bytes.len().saturating_add(chunk.len()) > config.max_response_body_bytes { + return Err(response_body_limit_error()); + } + bytes.extend_from_slice(&chunk); + } + return Ok(response_map(status, response_headers, bytes, &url)); + } + + Err(VmError::HostError( + "HTTP redirect processing failed".to_string(), + )) +} + +type BoxConnection = + Pin> + Send + 'static>>; + +pub(super) struct OwnedResponse { + connection: Option, + response: hyper::Response, +} + +impl OwnedResponse { + pub(super) fn response(&self) -> &hyper::Response { + &self.response + } + + pub(super) async fn next_frame( + &mut self, + ) -> VmResult>> { + enum Progress { + Frame(Option, hyper::Error>>), + Connection(Result<(), hyper::Error>), + } + + loop { + let Some(connection) = self.connection.as_mut() else { + let frame = + self.response + .body_mut() + .frame() + .await + .transpose() + .map_err(|error| { + VmError::HostError(format!("HTTP response read failed: {error}")) + })?; + if let Some(frame) = &frame { + validate_response_frame(frame)?; + } + return Ok(frame); + }; + let progress = tokio::select! { + biased; + frame = self.response.body_mut().frame() => Progress::Frame(frame), + result = connection.as_mut() => Progress::Connection(result), + }; + match progress { + Progress::Frame(frame) => { + let frame = frame.transpose().map_err(|error| { + VmError::HostError(format!("HTTP response read failed: {error}")) + })?; + if let Some(frame) = &frame { + validate_response_frame(frame)?; + } + return Ok(frame); + } + Progress::Connection(Ok(())) => self.connection = None, + Progress::Connection(Err(error)) => { + return Err(VmError::HostError(format!( + "HTTP connection failed: {error}" + ))); + } + } + } + } +} + +fn response_has_body(method: &hyper::Method, status: hyper::StatusCode) -> bool { + *method != hyper::Method::HEAD + && !status.is_informational() + && status != hyper::StatusCode::NO_CONTENT + && status != hyper::StatusCode::NOT_MODIFIED +} + +fn follows_location(status: hyper::StatusCode) -> bool { + matches!( + status, + hyper::StatusCode::MOVED_PERMANENTLY + | hyper::StatusCode::FOUND + | hyper::StatusCode::SEE_OTHER + | hyper::StatusCode::TEMPORARY_REDIRECT + | hyper::StatusCode::PERMANENT_REDIRECT + ) +} + +fn is_safe_cross_origin_redirect_header(name: &hyper::header::HeaderName) -> bool { + matches!( + name, + &hyper::header::ACCEPT | &hyper::header::ACCEPT_LANGUAGE | &hyper::header::ACCEPT_ENCODING + ) +} + +fn is_body_header(name: &hyper::header::HeaderName) -> bool { + matches!( + name, + &hyper::header::CONTENT_LENGTH + | &hyper::header::TRANSFER_ENCODING + | &hyper::header::CONTENT_TYPE + | &hyper::header::CONTENT_ENCODING + | &hyper::header::CONTENT_RANGE + | &hyper::header::TRAILER + | &hyper::header::TE + | &hyper::header::EXPECT + ) +} + +fn redirect_rewrites_to_get(status: hyper::StatusCode, method: &hyper::Method) -> bool { + (status == hyper::StatusCode::SEE_OTHER + && method != hyper::Method::GET + && method != hyper::Method::HEAD) + || ((status == hyper::StatusCode::MOVED_PERMANENTLY || status == hyper::StatusCode::FOUND) + && method == hyper::Method::POST) +} + +fn prepare_redirect( + current_url: &url::Url, + next_url: &url::Url, + status: hyper::StatusCode, + method: &mut hyper::Method, + body: &mut Option>, + headers: &mut Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>, +) { + if current_url.origin() != next_url.origin() { + headers.retain(|(name, _)| is_safe_cross_origin_redirect_header(name)); + } + if redirect_rewrites_to_get(status, method) { + *method = hyper::Method::GET; + *body = None; + headers.retain(|(name, _)| !is_body_header(name)); + } +} + +pub(super) fn response_header_entries(headers: &hyper::HeaderMap) -> Vec { + // HeaderMap iteration does not promise original cross-name wire order. + // HeaderName::as_str() is normalized, and the stable sort retains the + // HeaderMap-provided order among repeated values of the same name. + let mut entries = headers.iter().collect::>(); + entries.sort_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str())); + entries + .into_iter() + .map(|(name, value)| { + let value = if let Ok(text) = value.to_str() { + Value::Map(Arc::new(VmMap::from_entries(vec![ + (Value::string("kind"), Value::string("text")), + (Value::string("text"), Value::string(text)), + (Value::string("bytes"), Value::Null), + ]))) + } else { + Value::Map(Arc::new(VmMap::from_entries(vec![ + (Value::string("kind"), Value::string("bytes")), + (Value::string("text"), Value::Null), + ( + Value::string("bytes"), + Value::bytes(value.as_bytes().to_vec()), + ), + ]))) + }; + Value::Map(Arc::new(VmMap::from_entries(vec![ + (Value::string("name"), Value::string(name.as_str())), + (Value::string("value"), value), + ]))) + }) + .collect() +} + +pub(super) async fn open_stream_response( + config: &HttpConfig, + request: &HttpRequest, + observer: ResponseReadObserver, + opening_deadline: Instant, + opening_response_deadline: Instant, +) -> VmResult<(OwnedResponse, url::Url)> { + let mut method = request.method.clone(); + let mut url = request.url.clone(); + let mut body = request.body.clone(); + let mut headers = request.headers.clone(); + for redirect_index in 0..=config.max_redirects { + // Each hop may use the connect phase limit, but never beyond the one + // opening deadline supplied by the SSE lifecycle. + let connect_deadline = + super::policy::phase_deadline(opening_deadline, config.connect_timeout); + let resolved = with_deadline( + connect_deadline, + resolve_url(config, SchemeFamily::Http, &url), + ) + .await?; + let response = send_request( + &method, + &url, + &resolved, + &headers, + body.as_deref(), + ConnectionStage { + observer: observer.clone(), + deadline: connect_deadline, + response_deadline: Some(opening_response_deadline), + tls_config: None, + }, + ) + .await?; + validate_response_framing(response.response())?; + if follows_location(response.response().status()) { + if redirect_index == config.max_redirects { + return Err(VmError::HostError( + "HTTP redirect limit exceeded".to_string(), + )); + } + let location = response + .response() + .headers() + .get(hyper::header::LOCATION) + .ok_or_else(|| VmError::HostError("HTTP redirect has no location".to_string()))? + .to_str() + .map_err(|_| VmError::HostError("HTTP redirect location is invalid".to_string()))? + .to_string(); + let next_url = url + .join(&location) + .map_err(|error| VmError::HostError(format!("invalid HTTP redirect: {error}")))?; + super::policy::validate_url_policy(config, SchemeFamily::Http, &next_url)?; + prepare_redirect( + &url, + &next_url, + response.response().status(), + &mut method, + &mut body, + &mut headers, + ); + url = next_url; + continue; + } + return Ok((response, url)); + } + Err(VmError::HostError( + "HTTP redirect processing failed".to_string(), + )) +} + +fn response_map( + status: hyper::StatusCode, + headers: Vec, + body: Vec, + url: &url::Url, +) -> VmMap { + VmMap::from_entries(vec![ + ( + Value::string("status"), + Value::Int(i64::from(status.as_u16())), + ), + (Value::string("headers"), Value::array(headers)), + (Value::string("body"), Value::bytes(body)), + (Value::string("url"), Value::string(url.as_str())), + ]) +} + +fn validate_response_framing(response: &hyper::Response) -> VmResult<()> { + let headers = response.headers(); + let content_lengths: Vec<_> = headers + .get_all(hyper::header::CONTENT_LENGTH) + .iter() + .collect(); + let transfer_encodings: Vec<_> = headers + .get_all(hyper::header::TRANSFER_ENCODING) + .iter() + .collect(); + if !content_lengths.is_empty() && !transfer_encodings.is_empty() { + return Err(VmError::HostError( + "HTTP response has ambiguous transfer framing".to_string(), + )); + } + if content_lengths.len() > 1 { + return Err(VmError::HostError( + "HTTP response has ambiguous Content-Length".to_string(), + )); + } + let mut declared_length = None; + for value in content_lengths { + let length = value + .to_str() + .ok() + .and_then(|text| text.parse::().ok()) + .ok_or_else(|| { + VmError::HostError("HTTP response Content-Length is invalid".to_string()) + })?; + if declared_length.is_some_and(|previous| previous != length) { + return Err(VmError::HostError( + "HTTP response has ambiguous Content-Length".to_string(), + )); + } + declared_length = Some(length); + } + if !transfer_encodings.is_empty() { + let mut codings = transfer_encodings + .iter() + .flat_map(|value| value.to_str().unwrap_or("").split(',')) + .map(str::trim) + .filter(|coding| !coding.is_empty()); + if !codings + .next() + .is_some_and(|coding| coding.eq_ignore_ascii_case("chunked")) + || codings.next().is_some() + { + return Err(VmError::HostError( + "HTTP response has invalid Transfer-Encoding".to_string(), + )); + } + } + Ok(()) +} + +fn validate_response_trailers(headers: &hyper::HeaderMap) -> VmResult<()> { + let mut bytes = 0_usize; + for (name, value) in headers { + bytes = bytes + .checked_add(name.as_str().len()) + .and_then(|bytes| bytes.checked_add(value.len())) + .and_then(|bytes| bytes.checked_add(4)) + .ok_or_else(|| VmError::HostError("HTTP response trailers exceed limit".to_string()))?; + if bytes > HTTP_MAX_HEAD_BYTES { + return Err(VmError::HostError( + "HTTP response trailers exceed limit".to_string(), + )); + } + } + Ok(()) +} + +fn validate_response_frame(frame: &hyper::body::Frame) -> VmResult<()> { + if let Some(trailers) = frame.trailers_ref() { + validate_response_trailers(trailers)?; + } + Ok(()) +} + +fn response_body_limit_error() -> VmError { + VmError::HostError("HTTP response body exceeds limit".to_string()) +} + +fn reject_declared_oversize( + response: &hyper::Response, + limit: usize, +) -> VmResult<()> { + let Some(value) = response.headers().get(hyper::header::CONTENT_LENGTH) else { + return Ok(()); + }; + let length = value + .to_str() + .ok() + .and_then(|text| text.parse::().ok()) + .ok_or_else(|| VmError::HostError("HTTP response Content-Length is invalid".to_string()))?; + if length > limit as u64 { + return Err(response_body_limit_error()); + } + Ok(()) +} + +struct ConnectionStage { + observer: ResponseReadObserver, + deadline: Instant, + /// Bounds the response-header wait after the request is written. Streaming + /// adapters pass one absolute opening deadline; buffered requests leave + /// this `None` because their outer request deadline covers the whole call. + response_deadline: Option, + tls_config: Option>, +} + +async fn send_request( + method: &hyper::Method, + url: &url::Url, + resolved: &super::policy::ResolvedTarget, + headers: &[(hyper::header::HeaderName, hyper::header::HeaderValue)], + body: Option<&[u8]>, + stage: ConnectionStage, +) -> VmResult { + let ConnectionStage { + observer, + deadline: connect_deadline, + response_deadline, + tls_config, + } = stage; + let stream = with_deadline(connect_deadline, async { + tokio::net::TcpStream::connect(resolved.address) + .await + .map_err(|error| VmError::HostError(format!("HTTP request failed: {error}"))) + }) + .await?; + let peer = stream + .peer_addr() + .map_err(|error| VmError::HostError(format!("HTTP request failed: {error}")))?; + if peer != resolved.address { + return Err(VmError::HostError( + "HTTP connected peer does not match the validated address".to_string(), + )); + } + stream + .set_nodelay(true) + .map_err(|error| VmError::HostError(format!("HTTP request failed: {error}")))?; + + let raw = RawReadCapIo::new(stream); + if url.scheme() == "https" { + let mut tls_config = tls_config.map_or_else( + || { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth() + }, + Arc::unwrap_or_clone, + ); + tls_config.alpn_protocols = vec![b"http/1.1".to_vec()]; + let server_name = rustls::pki_types::ServerName::try_from(resolved.host.clone()) + .map_err(|_| VmError::HostError("HTTP TLS server name is invalid".to_string()))?; + let stream = with_deadline(connect_deadline, async { + tokio_rustls::TlsConnector::from(Arc::new(tls_config)) + .connect(server_name, raw) + .await + .map_err(|error| VmError::HostError(format!("HTTP request failed: {error}"))) + }) + .await?; + send_over_io( + method, + url, + headers, + body, + ReadCapIo::new(stream, observer), + response_deadline, + ) + .await + } else { + send_over_io( + method, + url, + headers, + body, + ReadCapIo::new(raw, observer), + response_deadline, + ) + .await + } +} + +async fn send_over_io( + method: &hyper::Method, + url: &url::Url, + headers: &[(hyper::header::HeaderName, hyper::header::HeaderValue)], + body: Option<&[u8]>, + io: ReadCapIo, + response_deadline: Option, +) -> VmResult +where + T: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let mut connection_builder = hyper::client::conn::http1::Builder::new(); + connection_builder + .read_buf_exact_size(Some(8 * 1024)) + .max_buf_size(HTTP_MAX_HEAD_BYTES * 2) + .max_headers(100); + let (mut sender, connection) = connection_builder + .handshake(hyper_util::rt::TokioIo::new(io)) + .await + .map_err(|error| VmError::HostError(format!("HTTP request failed: {error}")))?; + + let path_and_query = match url.query() { + Some(query) => format!("{}?{query}", url.path()), + None => url.path().to_string(), + }; + let mut builder = hyper::Request::builder() + .method(method.clone()) + .uri(path_and_query) + .header( + hyper::header::HOST, + &url[url::Position::BeforeHost..url::Position::AfterPort], + ); + for (name, value) in headers { + builder = builder.header(name, value); + } + let request_body = http_body_util::Full::new(hyper::body::Bytes::copy_from_slice( + body.unwrap_or_default(), + )); + let request = builder + .body(request_body) + .map_err(|error| VmError::HostError(format!("HTTP request setup failed: {error}")))?; + let mut connection: BoxConnection = Box::pin(connection); + // The response wait (including the request write) is bounded by the + // absolute opening deadline when one is supplied. That deadline was + // captured before stream admission and is never recreated per redirect; + // buffered requests use their outer request deadline instead. + let send_response = async { + let response = sender.send_request(request); + tokio::pin!(response); + let (response, connection) = { + tokio::select! { + biased; + response = &mut response => ( + response.map_err(|error| { + VmError::HostError(format!("HTTP request failed: {error}")) + })?, + Some(connection), + ), + connection_result = connection.as_mut() => { + let response_result = response.await; + let response = match (connection_result, response_result) { + (_, Ok(response)) => response, + (Ok(()), Err(error)) => { + return Err(VmError::HostError(format!( + "HTTP request failed: {error}" + ))); + } + (Err(connection_error), Err(request_error)) => { + return Err(VmError::HostError(format!( + "HTTP connection failed before the response: {connection_error}; request failed: {request_error}" + ))); + } + }; + (response, None) + } + } + }; + Ok::<_, VmError>((response, connection)) + }; + let (response, connection) = match response_deadline { + Some(deadline) => with_deadline(deadline, send_response).await?, + None => send_response.await?, + }; + Ok(OwnedResponse { + connection, + response, + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; + + use super::{ + BufferedRequestShared, FAIL_NEXT_WORKER_SPAWN, HTTP_MAX_HEAD_BYTES, HttpRequestOperation, + HttpRequestResource, REJECT_NEXT_OPERATION_ADMISSION, ReadCapIo, RequestHeaderBudget, + ResponseReadObserver, WorkerLifecycle, parse_request, parse_request_body, + rollback_buffered_request, spawn_worker, start_operation, validate_response_trailers, + }; + use crate::builtins::runtime::typed::VmMap; + use crate::vm::{Value, VmError}; + + fn empty_vm() -> crate::vm::Vm { + crate::vm::Vm::new(crate::vm::Program::new( + Vec::new(), + vec![crate::vm::OpCode::Ret as u8], + )) + } + + fn buffered_shared() -> std::sync::Arc { + let permit = crate::builtins::runtime::http::policy::ConnectionAdmission::new(1) + .acquire() + .expect("test permit"); + std::sync::Arc::new(BufferedRequestShared { + cancel: tokio::sync::Notify::new(), + result: std::sync::Mutex::new(None), + done: AtomicBool::new(false), + thread_finished: AtomicBool::new(false), + worker_lifecycle: AtomicU8::new(WorkerLifecycle::NotStarted as u8), + waker: futures_util::task::AtomicWaker::new(), + join_handle: std::sync::Mutex::new(None), + close_waker: futures_util::task::AtomicWaker::new(), + quiescence_waker: futures_util::task::AtomicWaker::new(), + _permit: permit, + rollback_finished: AtomicBool::new(false), + }) + } + + fn request_with_body(body: Value) -> VmMap { + let mut request = VmMap::new(); + request.insert(Value::string("method"), Value::string("POST")); + request.insert(Value::string("url"), Value::string("http://example.test/")); + request.insert(Value::string("body"), body); + request + } + + fn value_map(entries: impl IntoIterator) -> Value { + Value::Map(Arc::new(VmMap::from_entries( + entries + .into_iter() + .map(|(key, value)| (Value::string(key), value)) + .collect(), + ))) + } + + fn request_with_headers(headers: Vec<(&str, &str)>) -> VmMap { + let mut request = VmMap::new(); + request.insert(Value::string("method"), Value::string("GET")); + request.insert(Value::string("url"), Value::string("http://example.test/")); + request.insert( + Value::string("headers"), + Value::Array(Arc::new( + headers + .into_iter() + .map(|(name, value)| { + Value::Map(std::sync::Arc::new(VmMap::from_entries(vec![ + (Value::string("name"), Value::string(name)), + (Value::string("value"), Value::string(value)), + ]))) + }) + .collect(), + )), + ); + request + } + + #[test] + fn request_body_payload_checks_limit_before_copying() { + let exact_config = crate::builtins::runtime::http::HttpConfig { + max_request_body_bytes: 7, + ..Default::default() + }; + let text = value_map([ + ("kind", Value::string("text")), + ("text", Value::string("payload")), + ]); + assert_eq!( + parse_request_body(Some(&text), &exact_config) + .expect("exact text limit should be accepted"), + Some(b"payload".to_vec()) + ); + + let zero_config = crate::builtins::runtime::http::HttpConfig { + max_request_body_bytes: 0, + ..Default::default() + }; + let empty_bytes = value_map([ + ("kind", Value::string("bytes")), + ("bytes", Value::bytes(Vec::new())), + ]); + assert_eq!( + parse_request_body(Some(&empty_bytes), &zero_config) + .expect("zero-length bytes should fit zero limit"), + Some(Vec::new()) + ); + + let limited_config = crate::builtins::runtime::http::HttpConfig { + max_request_body_bytes: 6, + ..Default::default() + }; + let text_error = parse_request_body(Some(&text), &limited_config) + .expect_err("one byte over the text limit must be rejected before copying"); + assert!(matches!( + text_error, + VmError::HostError(message) if message == "HTTP request body exceeds limit" + )); + + let bytes = value_map([ + ("kind", Value::string("bytes")), + ("bytes", Value::bytes(b"payload".to_vec())), + ]); + let bytes_error = parse_request_body(Some(&bytes), &limited_config) + .expect_err("one byte over the bytes limit must be rejected before copying"); + assert!(matches!( + bytes_error, + VmError::HostError(message) if message == "HTTP request body exceeds limit" + )); + } + + #[test] + fn request_body_discriminator_rejects_invalid_variants() { + let config = crate::builtins::runtime::http::HttpConfig::default(); + let cases = [ + ( + value_map([ + ("kind", Value::string("json")), + ("text", Value::string("payload")), + ]), + "HTTP request body kind must", + ), + ( + value_map([("kind", Value::string("text"))]), + "HTTP request body text payload", + ), + ( + value_map([("kind", Value::string("bytes"))]), + "HTTP request body bytes payload", + ), + ( + value_map([ + ("kind", Value::string("text")), + ("text", Value::string("payload")), + ("bytes", Value::bytes(b"raw".to_vec())), + ]), + "text variant cannot contain bytes", + ), + ( + value_map([ + ("kind", Value::string("bytes")), + ("text", Value::string("payload")), + ("bytes", Value::bytes(b"raw".to_vec())), + ]), + "bytes variant cannot contain text", + ), + ( + value_map([ + ("kind", Value::string("text")), + ("text", Value::string("payload")), + ("extra", Value::Null), + ]), + "contains unknown field 'extra'", + ), + ]; + for (body, expected) in cases { + let error = match parse_request(&request_with_body(body), &config) { + Ok(_) => panic!("invalid request body variant was accepted"), + Err(error) => error, + }; + assert!( + error.to_string().contains(expected), + "expected {expected:?}, got {error}" + ); + } + } + + #[test] + fn request_header_budget_counts_wire_overhead_at_exact_boundary() { + let mut budget = RequestHeaderBudget::new(1, 8); + budget + .admit(b"x", b"y") + .expect("one header line is four bytes"); + budget.finish().expect("the final CRLF is two bytes"); + assert_eq!(budget.count(), 1); + assert_eq!(budget.bytes(), 8); + } + + #[test] + fn request_header_budget_rejects_over_limit_before_header_conversion() { + let config = crate::builtins::runtime::http::HttpConfig { + max_request_header_count: 1, + max_request_header_bytes: 8, + ..Default::default() + }; + let error = match parse_request(&request_with_headers(vec![("x", "yy")]), &config) { + Ok(_) => panic!("header line plus terminator exceeds eight bytes"), + Err(error) => error, + }; + assert!(matches!(error, VmError::HostError(message) if message.contains("header bytes"))); + } + + #[test] + fn request_header_budget_rejects_many_tiny_headers_by_count_and_bytes() { + let mut count_limited = RequestHeaderBudget::new(2, 1024); + count_limited.admit(b"a", b"b").unwrap(); + count_limited.admit(b"c", b"d").unwrap(); + let error = count_limited.admit(b"e", b"f").unwrap_err(); + assert!(error.to_string().contains("header count")); + + let mut bytes_limited = RequestHeaderBudget::new(16, 13); + bytes_limited.admit(b"a", b"b").unwrap(); + bytes_limited.admit(b"c", b"d").unwrap(); + let error = bytes_limited.finish().unwrap_err(); + assert!(error.to_string().contains("header bytes")); + } + + #[test] + fn response_trailer_budget_rejects_aggregate_without_per_field_overflow() { + let mut headers = hyper::HeaderMap::new(); + let value = hyper::header::HeaderValue::from_static( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + for index in 0..1_100 { + let name = + hyper::header::HeaderName::from_bytes(format!("x-trailer-{index}").as_bytes()) + .unwrap(); + headers.append(name, value.clone()); + } + let error = validate_response_trailers(&headers).unwrap_err(); + assert!(error.to_string().contains("trailers")); + } + + #[test] + fn response_head_budget_accepts_exact_limit_and_rejects_one_byte_over() { + fn head_with_size(size: usize) -> Vec { + let prefix = b"HTTP/1.1 204 No Content\r\nX-Pad: "; + let suffix = b"\r\n\r\n"; + let value_len = size - prefix.len() - suffix.len(); + let mut head = Vec::with_capacity(size); + head.extend_from_slice(prefix); + head.extend(std::iter::repeat_n(b'a', value_len)); + head.extend_from_slice(suffix); + assert_eq!(head.len(), size); + head + } + + let exact = head_with_size(HTTP_MAX_HEAD_BYTES); + let mut exact_io = ReadCapIo::new(tokio::io::empty(), ResponseReadObserver::default()); + for byte in exact { + exact_io + .observe_head_byte(byte) + .expect("exact response head should be admitted"); + } + assert!(exact_io.header_complete); + + let over = head_with_size(HTTP_MAX_HEAD_BYTES + 1); + let mut over_io = ReadCapIo::new(tokio::io::empty(), ResponseReadObserver::default()); + let error = over + .into_iter() + .try_for_each(|byte| over_io.observe_head_byte(byte)) + .expect_err("one byte over the response-head limit must fail"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn admission_rollback_reclaims_workerless_request_resource() { + let mut vm = empty_vm(); + let shared = buffered_shared(); + let token = vm + .execution_scope() + .push_resource(HttpRequestResource::new(std::sync::Arc::clone(&shared))) + .expect("request resource"); + let primary = VmError::HostError("operation admission rejected".to_string()); + REJECT_NEXT_OPERATION_ADMISSION.store(true, Ordering::Release); + let admission = start_operation(&mut vm, HttpRequestOperation::new(Arc::clone(&shared))); + assert!(admission.is_err()); + + let error = rollback_buffered_request(&mut vm, token.handle(), &shared, None, primary); + + assert!(error.to_string().contains("operation admission rejected")); + assert_eq!(vm.execution_scope().resources().len(), 0); + assert_eq!( + shared.worker_lifecycle.load(Ordering::Acquire), + WorkerLifecycle::Finished as u8 + ); + assert!(shared.done.load(Ordering::Acquire)); + assert!(shared.thread_finished.load(Ordering::Acquire)); + + let repeated = rollback_buffered_request( + &mut vm, + token.handle(), + &shared, + None, + VmError::HostError("repeated rollback".to_string()), + ); + assert!(repeated.to_string().contains("repeated rollback")); + } + + #[test] + fn worker_spawn_abstraction_can_inject_a_builder_failure() { + FAIL_NEXT_WORKER_SPAWN.store(true, Ordering::Release); + let result = spawn_worker("injected-http-worker", || {}); + let error = match result { + Ok(handle) => { + handle.join().expect("unexpected worker"); + panic!("spawn should have been rejected") + } + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("injected HTTP worker spawn failure") + ); + } +} diff --git a/src/builtins/runtime/http/sse.rs b/src/builtins/runtime/http/sse.rs new file mode 100644 index 00000000..98134616 --- /dev/null +++ b/src/builtins/runtime/http/sse.rs @@ -0,0 +1,1806 @@ +use std::future::Future; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +use futures_util::task::AtomicWaker; +use pd_host_function::pd_host_function; +use tokio::sync::{Notify, mpsc}; + +use super::request::{ + HttpRequest, OwnedResponse, ResponseReadObserver, open_stream_response, parse_request, + response_header_entries, validate_request_header_budget, +}; +use super::{HttpRequestContext, policy}; +use crate::builtins::runtime::HostCallResult; +use crate::builtins::runtime::typed::{VmCallable, VmMap, VmMapHandle}; +use crate::host_api::ResourceTypeKey; +use crate::vm::async_host::{ + HostStreamAction, HostStreamDriver, HostStreamPoll, HostStreamTermination, +}; +use crate::vm::operation::{ + HostOperation, OperationCancelReason, OperationError, OperationErrorCode, OperationId, + OperationResult, OperationSpec, +}; +use crate::vm::resource::{ + CloseProgress, HostResource, ResourceCloseReason, ResourceError, ResourceErrorCode, + ResourceHandle, ResourceResult, +}; +use crate::vm::{ + CallOutcome, Value, Vm, VmError, VmResult, + execution_scope::{ExecutionScope, ExecutionScopeError}, +}; + +/// Maximum number of SSE items buffered between the worker and the stream +/// driver before publishing applies backpressure. A small bounded queue +/// preserves ordering without letting the worker run arbitrarily far ahead of +/// the per-item callback, and without unbounded memory growth on a slow or +/// stalled callback. The worker blocks on an under-capacity send, which keeps +/// it in sync with the driver and prevents both event loss and runaway queue +/// growth. +const SSE_CHANNEL_CAPACITY: usize = 1; + +#[cfg(test)] +const _: () = assert!(SSE_CHANNEL_CAPACITY == 1); + +/// The error surfaced when the absolute stream deadline (the minimum of the +/// host maximum stream duration and the script `timeout_ms`) is exceeded. +const SSE_TOTAL_DEADLINE_ERROR: &str = "SSE total deadline exceeded"; + +#[derive(Debug, PartialEq, Eq)] +struct SseEvent { + event: Option, + data: String, + id: Option, + retry_ms: Option, +} + +/// Incremental EventSource parser. `max_total_bytes` counts raw response-body +/// octets, including a BOM and line terminators. `max_item_bytes` counts the +/// UTF-8 bytes retained in data (including inserted joins), event, and id. +struct SseParser { + max_line_bytes: usize, + max_item_bytes: usize, + max_total_bytes: usize, + total_bytes: usize, + prefix: Vec, + bom_decided: bool, + line: Vec, + after_cr: bool, + data: String, + has_data: bool, + event: Option, + id: Option, + retry_ms: Option, + finished: bool, +} + +impl SseParser { + fn new(max_line_bytes: usize, max_item_bytes: usize, max_total_bytes: usize) -> Self { + Self { + max_line_bytes, + max_item_bytes, + max_total_bytes, + total_bytes: 0, + prefix: Vec::with_capacity(3), + bom_decided: false, + line: Vec::with_capacity(max_line_bytes.min(1024)), + after_cr: false, + data: String::new(), + has_data: false, + event: None, + id: None, + retry_ms: None, + finished: false, + } + } + + #[cfg(test)] + fn push(&mut self, bytes: &[u8]) -> VmResult> { + self.admit_chunk(bytes.len())?; + let mut events = Vec::new(); + let mut offset = 0; + while offset < bytes.len() { + let (consumed, event) = self.push_until_event(&bytes[offset..])?; + offset += consumed; + if let Some(event) = event { + events.push(event); + } + } + Ok(events) + } + + fn admit_chunk(&mut self, bytes: usize) -> VmResult<()> { + self.total_bytes = self + .total_bytes + .checked_add(bytes) + .filter(|total| *total <= self.max_total_bytes) + .ok_or_else(|| VmError::HostError("SSE stream exceeds total byte limit".to_string()))?; + Ok(()) + } + + fn push_until_event(&mut self, bytes: &[u8]) -> VmResult<(usize, Option)> { + if self.finished { + return Err(VmError::HostError( + "SSE parser received bytes after EOF".to_string(), + )); + } + let mut consumed = 0; + while consumed < bytes.len() { + let byte = bytes[consumed]; + consumed += 1; + if !self.bom_decided { + self.prefix.push(byte); + if self.prefix == b"\xef\xbb\xbf" { + self.prefix.clear(); + self.bom_decided = true; + continue; + } + if b"\xef\xbb\xbf".starts_with(&self.prefix) { + continue; + } + let prefix = std::mem::take(&mut self.prefix); + self.bom_decided = true; + for byte in prefix { + if let Some(event) = self.process_byte(byte)? { + return Ok((consumed, Some(event))); + } + } + continue; + } + if let Some(event) = self.process_byte(byte)? { + return Ok((consumed, Some(event))); + } + } + Ok((consumed, None)) + } + + fn finish(&mut self) -> VmResult> { + if self.finished { + return Ok(Vec::new()); + } + self.finished = true; + let mut events = Vec::new(); + if !self.prefix.is_empty() { + let prefix = std::mem::take(&mut self.prefix); + for byte in prefix { + if let Some(event) = self.process_byte(byte)? { + events.push(event); + } + } + } + if !self.line.is_empty() + && let Some(event) = self.process_line()? + { + events.push(event); + } + // EventSource dispatches only on a blank line. EOF discards a partial + // event, including a final unterminated data line. + self.data.clear(); + self.has_data = false; + self.event = None; + Ok(events) + } + + fn process_byte(&mut self, byte: u8) -> VmResult> { + if self.after_cr { + self.after_cr = false; + if byte == b'\n' { + return Ok(None); + } + } + match byte { + b'\r' => { + let event = self.process_line()?; + self.after_cr = true; + Ok(event) + } + b'\n' => self.process_line(), + _ => { + if self.line.len() == self.max_line_bytes { + return Err(VmError::HostError( + "SSE line exceeds byte limit".to_string(), + )); + } + self.line.push(byte); + Ok(None) + } + } + } + + fn process_line(&mut self) -> VmResult> { + let bytes = std::mem::take(&mut self.line); + let line = std::str::from_utf8(&bytes) + .map_err(|_| VmError::HostError("SSE stream contains malformed UTF-8".to_string()))?; + if line.is_empty() { + if self.data_seen() { + return Ok(Some(self.dispatch_event())); + } + // The WHATWG dispatch algorithm clears both data and event type + // buffers even when empty data causes dispatch to return early. + self.event = None; + return Ok(None); + } + if line.starts_with(':') { + return Ok(None); + } + let (field, mut value) = line.split_once(':').unwrap_or((line, "")); + if let Some(rest) = value.strip_prefix(' ') { + value = rest; + } + match field { + "data" => { + let added = value.len() + usize::from(self.has_data); + self.ensure_item_growth(added, self.event.as_deref(), self.id.as_deref())?; + if self.has_data { + self.data.push('\n'); + } + self.data.push_str(value); + self.has_data = true; + } + "event" => { + self.ensure_item_size(self.data.len(), Some(value), self.id.as_deref())?; + self.event = Some(value.to_string()); + } + "id" if !value.contains('\0') => { + self.ensure_item_size(self.data.len(), self.event.as_deref(), Some(value))?; + self.id = Some(value.to_string()); + } + "retry" if !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) => { + if let Ok(retry) = value.parse::() { + self.retry_ms = Some(retry); + } + } + _ => {} + } + Ok(None) + } + + fn data_seen(&self) -> bool { + self.has_data + } + + fn ensure_item_growth( + &self, + added: usize, + event: Option<&str>, + id: Option<&str>, + ) -> VmResult<()> { + let data = self + .data + .len() + .checked_add(added) + .ok_or_else(item_limit_error)?; + self.ensure_item_size(data, event, id) + } + + fn ensure_item_size( + &self, + data_bytes: usize, + event: Option<&str>, + id: Option<&str>, + ) -> VmResult<()> { + let size = data_bytes + .checked_add(event.map_or(0, str::len)) + .and_then(|size| size.checked_add(id.map_or(0, str::len))) + .ok_or_else(item_limit_error)?; + if size > self.max_item_bytes { + return Err(item_limit_error()); + } + Ok(()) + } + + fn dispatch_event(&mut self) -> SseEvent { + let data = std::mem::take(&mut self.data); + self.has_data = false; + SseEvent { + event: self.event.take(), + data, + id: self.id.clone(), + retry_ms: self.retry_ms, + } + } +} + +fn item_limit_error() -> VmError { + VmError::HostError("SSE item exceeds byte limit".to_string()) +} + +fn map_value(entries: Vec<(&'static str, Value)>) -> Value { + Value::Map(std::sync::Arc::new(VmMap::from_entries( + entries + .into_iter() + .map(|(key, value)| (Value::string(key), value)) + .collect(), + ))) +} + +fn sse_open_event(status: u16, headers: Arc>, url: &str) -> Value { + map_value(vec![ + ("kind", Value::string("open")), + ("status", Value::Int(i64::from(status))), + ("headers", Value::Array(headers)), + ("url", Value::string(url)), + ("event", Value::Null), + ("data", Value::Null), + ("id", Value::Null), + ("retry_ms", Value::Null), + ]) +} + +fn sse_data_event(event: SseEvent) -> Value { + map_value(vec![ + ("kind", Value::string("event")), + ("status", Value::Null), + ("headers", Value::Null), + ("url", Value::Null), + ("event", event.event.map_or(Value::Null, Value::string)), + ("data", Value::string(event.data)), + ("id", event.id.map_or(Value::Null, Value::string)), + ("retry_ms", event.retry_ms.map_or(Value::Null, Value::Int)), + ]) +} + +fn sse_end_event() -> Value { + map_value(vec![ + ("kind", Value::string("end")), + ("status", Value::Null), + ("headers", Value::Null), + ("url", Value::Null), + ("event", Value::Null), + ("data", Value::Null), + ("id", Value::Null), + ("retry_ms", Value::Null), + ]) +} + +fn parse_stream_timeout(request: &VmMap) -> VmResult> { + match request.get(&Value::string("timeout_ms")) { + None | Some(Value::Null) => Ok(None), + Some(Value::Int(milliseconds)) => { + let milliseconds = u64::try_from(*milliseconds) + .ok() + .filter(|milliseconds| *milliseconds > 0) + .ok_or_else(|| VmError::HostError("SSE timeout_ms must be positive".to_string()))?; + Ok(Some(Duration::from_millis(milliseconds))) + } + Some(_) => Err(VmError::TypeMismatch("SSE timeout_ms")), + } +} + +/// Shared SSE stream state owned by the child [`SseStreamResource`]. +/// +/// The child resource is registered under the opened response stream +/// resource, so the generic child-first scope shutdown closes the SSE reader +/// before its underlying response stream. The stop flag is set by the child's +/// [`HostResource::begin_close`] and by the SSE poll operation's cancel; the +/// worker observes it between items and stops promptly. +#[repr(u8)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SseWorkerLifecycle { + NotStarted = 0, + Running = 1, + Finished = 2, +} + +pub(super) struct SseShared { + /// Set on close/cancel; the worker stops polling the network. + pub(super) stopping: AtomicBool, + /// Notified on close/cancel so the worker can break out of a + /// blocking network read. Race-free: if notify_one() arrives before + /// the worker starts waiting, the next notified() completes immediately. + pub(super) cancel: Notify, + /// The first cancellation reason is retained for the producer and cleanup + /// diagnostics. Later cancellation requests cannot overwrite it. + pub(super) cancellation_reason: std::sync::Mutex>, + /// One acknowledgement is issued by the VM after each callback. The + /// acknowledgement arrives. + pub(super) item_ack: Notify, + /// Waker registered by a pending stream poll. Channel readiness is handled + /// by `Receiver::poll_recv`; this waker covers stop and terminal state. + pub(super) waker: AtomicWaker, + /// Bounded FIFO of published items awaiting the stream driver. + /// The worker `send`s with backpressure; the driver `try_recv`s. + /// This preserves item ordering and never drops events, unlike a + /// single-slot overwrite slot. + pub(super) items: mpsc::Sender, + /// Set when the worker thread has finished running. + pub(super) done: AtomicBool, + /// Set by the spawned closure after the worker entry has returned. + pub(super) thread_finished: AtomicBool, + /// Explicit worker lifecycle. Workerless rollback may transition only from + /// `NotStarted` to `Finished`. + pub(super) worker_lifecycle: std::sync::atomic::AtomicU8, + /// The final result from the worker thread (Ok or error). + pub(super) result: std::sync::Mutex>>, + /// The worker thread handle, taken during close to join. + pub(super) join_handle: std::sync::Mutex>>, + /// Waker registered by the close poll when the worker is still running. + pub(super) close_waker: AtomicWaker, + /// Waker registered by the scoped operation while the producer is still + /// running. + pub(super) quiescence_waker: AtomicWaker, + /// The permit is owned by shared stream state, so dropping the driver + /// cannot release admission while the worker or transport is alive. + pub(super) _permit: super::ConnectionPermit, + /// Set after rollback has retired both the operation and resource. + pub(super) rollback_finished: AtomicBool, +} + +impl SseShared { + fn mark_worker_running(&self) { + let _ = self.worker_lifecycle.compare_exchange( + SseWorkerLifecycle::NotStarted as u8, + SseWorkerLifecycle::Running as u8, + Ordering::AcqRel, + Ordering::Acquire, + ); + } + + fn mark_worker_finished(&self) { + self.thread_finished.store(true, Ordering::Release); + self.worker_lifecycle + .store(SseWorkerLifecycle::Finished as u8, Ordering::Release); + self.close_waker.wake(); + self.quiescence_waker.wake(); + } + + fn terminalize_workerless(&self, reason: OperationCancelReason, result: VmResult<()>) -> bool { + if self + .worker_lifecycle + .compare_exchange( + SseWorkerLifecycle::NotStarted as u8, + SseWorkerLifecycle::Finished as u8, + Ordering::AcqRel, + Ordering::Acquire, + ) + .is_err() + { + return false; + } + self.request_stop(reason); + self.publish(result); + self.thread_finished.store(true, Ordering::Release); + self.close_waker.wake(); + self.quiescence_waker.wake(); + true + } + + fn request_stop(&self, reason: OperationCancelReason) { + self.stopping.store(true, Ordering::Release); + let mut cancellation = self + .cancellation_reason + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if cancellation.is_none() { + *cancellation = Some(reason); + } + self.cancel.notify_one(); + self.cancel.notify_waiters(); + self.waker.wake(); + self.close_waker.wake(); + self.quiescence_waker.wake(); + } + + fn publish(&self, result: VmResult<()>) { + *self + .result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(result); + // The result mutex write happens-before this release publication. + self.done.store(true, Ordering::Release); + self.waker.wake(); + self.close_waker.wake(); + self.quiescence_waker.wake(); + } + + fn take_result(&self) -> Option> { + self.result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + } + + fn is_quiescent(&self) -> bool { + self.worker_lifecycle.load(Ordering::Acquire) == SseWorkerLifecycle::Finished as u8 + && self.done.load(Ordering::Acquire) + && self.thread_finished.load(Ordering::Acquire) + && self + .join_handle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_none() + } + + fn join_worker(&self) -> Result<(), String> { + if self.worker_lifecycle.load(Ordering::Acquire) != SseWorkerLifecycle::Finished as u8 + || !self.done.load(Ordering::Acquire) + || !self.thread_finished.load(Ordering::Acquire) + { + return Err("SSE worker is still running".to_string()); + } + if self + .join_handle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .is_some_and(|handle| !handle.is_finished()) + { + return Err("SSE worker thread has not exited".to_string()); + } + let handle = self + .join_handle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + let Some(handle) = handle else { + return Ok(()); + }; + handle.join().map_err(|panic| { + let message = worker_panic_message(&panic); + match self.cancellation_reason() { + Some(reason) => format!("{message} (cancellation reason: {reason})"), + None => message, + } + }) + } + + fn try_join_finished(&self) -> ResourceResult { + if self.worker_lifecycle.load(Ordering::Acquire) != SseWorkerLifecycle::Finished as u8 + || !self.done.load(Ordering::Acquire) + || !self.thread_finished.load(Ordering::Acquire) + { + return Ok(false); + } + let handle = { + let mut guard = self + .join_handle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if guard.as_ref().is_some_and(|handle| !handle.is_finished()) { + return Ok(false); + } + guard.take() + }; + let Some(handle) = handle else { + return Ok(true); + }; + handle + .join() + .map(|_| true) + .map_err(|panic| resource_cleanup_error(&worker_panic_message(&panic))) + } + + fn cancellation_reason(&self) -> Option { + self.cancellation_reason + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .copied() + } +} + +fn worker_panic_message(panic: &Box) -> String { + if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = panic.downcast_ref::() { + message.clone() + } else { + "SSE worker thread panicked".to_string() + } +} + +#[cfg(test)] +static FAIL_NEXT_WORKER_SPAWN: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +fn spawn_worker(name: &str, function: F) -> std::io::Result> +where + F: FnOnce() + Send + 'static, +{ + #[cfg(test)] + if FAIL_NEXT_WORKER_SPAWN.swap(false, Ordering::AcqRel) { + return Err(std::io::Error::other("injected SSE worker spawn failure")); + } + std::thread::Builder::new() + .name(name.to_string()) + .spawn(function) +} + +#[cfg(test)] +static REJECT_NEXT_OPERATION_ADMISSION: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +#[allow(clippy::result_large_err)] +fn start_operation( + vm: &mut Vm, + operation: T, +) -> crate::vm::host_context::HostContextResult { + #[cfg(test)] + if REJECT_NEXT_OPERATION_ADMISSION.swap(false, Ordering::AcqRel) { + return Err(crate::vm::host_context::HostContextError::new( + "http::operation", + "injected operation admission rejection", + )); + } + vm.host_context() + .start_operation(OperationSpec::new(operation)) +} + +fn resource_cleanup_error(message: &str) -> ResourceError { + ResourceError::new( + ResourceErrorCode::ResourceCleanupFailed, + "http::sse::resource", + message, + ) +} + +/// Runs the whole SSE lifecycle on a worker thread: open the response stream +/// (following redirects), validate it, read body frames, parse events and +/// publish each item into the shared completion channel. The guest callback +/// is invoked by the VM between items via the pending-result adapter. +struct SseWorker { + config: super::HttpConfig, + request: HttpRequest, + /// The one absolute stream deadline captured at stream admission. It is + /// passed unchanged through opening, redirects, body reads, and delivery. + deadline: Instant, + shared: Arc, + items: Arc, + bytes_received: Arc, + status: std::sync::Mutex>, + headers: std::sync::Mutex>>>, + url: std::sync::Mutex>, +} + +impl SseWorker { + fn run(self: Arc) { + // The permit is held by shared stream state until cleanup completes. + let result = + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner())) { + Ok(result) => result, + Err(panic) => Err(VmError::HostError(format!( + "SSE worker panicked: {}", + worker_panic_message(&panic) + ))), + }; + self.shared.publish(result); + } + + fn run_inner(&self) -> VmResult<()> { + // The entire SSE network lifecycle (open the response stream, then + // read every body frame) MUST run inside a single Tokio runtime. The + // owned response ties the hyper connection future and body receiver to + // one I/O driver; recreating a fresh current-thread runtime per frame + // moves a live socket across reactors and corrupts the body framing, + // surfacing hyper errors like "error reading a body from connection". + runtime_block_on(self.stream_lifecycle())? + } + + async fn stream_lifecycle(self: &SseWorker) -> VmResult<()> { + let mut parser = SseParser::new( + self.config.max_sse_line_bytes, + self.config.max_stream_item_bytes, + self.config.max_stream_total_bytes, + ); + let observer = ResponseReadObserver::default(); + + // The absolute deadline was captured before admission and is shared by + // every opening hop, body read, and callback publication. + let deadline = self.deadline; + + // Opening response headers must arrive before the earlier of the + // captured total deadline and this opening phase's idle boundary. + let opening_idle_deadline = + super::policy::phase_deadline(deadline, self.config.stream_idle_timeout); + let (mut response, url) = self + .open_response(observer.clone(), opening_idle_deadline) + .await?; + let status = response.response().status(); + if !status.is_success() { + return Err(VmError::HostError(format!( + "SSE response status {} is not successful", + status.as_u16() + ))); + } + let content_type = response + .response() + .headers() + .get(hyper::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .map(str::trim) + .filter(|value| value.eq_ignore_ascii_case("text/event-stream")) + .ok_or_else(|| { + VmError::HostError( + "SSE response Content-Type must be text/event-stream".to_string(), + ) + })?; + let _ = content_type; + let headers = Arc::new(response_header_entries(response.response().headers())); + *self + .status + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(status.as_u16()); + *self + .headers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Arc::clone(&headers)); + *self + .url + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(url.to_string()); + observer.admit_body(self.config.max_stream_total_bytes); + self.publish( + sse_open_event(status.as_u16(), headers, url.as_str()), + deadline, + ) + .await?; + + // Body phase: every delivered frame resets the idle deadline, while + // the absolute total deadline is computed once and never reset by + // progress. + let mut idle_deadline = + super::policy::phase_deadline(deadline, self.config.stream_idle_timeout); + loop { + if self.shared.stopping.load(Ordering::SeqCst) { + return Err(VmError::HostError("SSE stream closed".to_string())); + } + let frame = self + .next_frame(&mut response, idle_deadline, deadline) + .await?; + let Some(frame) = frame else { + break; + }; + let Ok(data) = frame.into_data() else { + continue; + }; + // Any delivered body bytes count as progress: reset the idle + // deadline, but never touch the absolute total deadline. + idle_deadline = + super::policy::phase_deadline(deadline, self.config.stream_idle_timeout); + parser.admit_chunk(data.len())?; + observer.observe_application_chunk(data.len()); + self.bytes_received.fetch_add(data.len(), Ordering::SeqCst); + let mut offset = 0; + while offset < data.len() { + let (consumed, event) = parser.push_until_event(&data[offset..])?; + offset += consumed; + if let Some(event) = event { + self.items.fetch_add(1, Ordering::SeqCst); + self.publish(sse_data_event(event), deadline).await?; + } + } + } + parser.finish()?; + self.publish(sse_end_event(), deadline).await + } + + /// Opens the response stream with one absolute deadline shared by DNS, + /// connection setup, TLS, request/response headers, and every redirect. + /// The outer select also applies the opening idle phase limit; whichever + /// boundary is earlier wins. + /// + /// Cancellation is selected alongside the opening deadlines. Dropping the + /// whole opening future also drops every DNS/connect/TLS/header future and + /// every redirect hop owned by `open_stream_response`. + async fn open_response( + &self, + observer: ResponseReadObserver, + opening_idle_deadline: Instant, + ) -> VmResult<(OwnedResponse, url::Url)> { + if self.shared.stopping.load(Ordering::Acquire) { + return Err(VmError::HostError("SSE stream cancelled".to_string())); + } + tokio::select! { + biased; + _ = self.shared.cancel.notified() => { + Err(VmError::HostError("SSE stream cancelled".to_string())) + } + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(opening_idle_deadline)) => { + if self.deadline <= opening_idle_deadline { + Err(VmError::HostError(SSE_TOTAL_DEADLINE_ERROR.to_string())) + } else { + Err(VmError::HostError( + "SSE stream idle timeout while opening response".to_string(), + )) + } + } + opened = open_stream_response( + &self.config, + &self.request, + observer, + self.deadline, + opening_idle_deadline, + ) => { + opened.map_err(|error| { + if error.to_string().contains("HTTP request deadline exceeded") { + let now = Instant::now(); + if now >= self.deadline { + VmError::HostError(SSE_TOTAL_DEADLINE_ERROR.to_string()) + } else if now >= opening_idle_deadline { + VmError::HostError( + "SSE stream idle timeout while opening response".to_string(), + ) + } else { + error + } + } else { + error + } + }) + } + } + } + + /// Reads one body frame bounded by cancel, the absolute total deadline and + /// the current idle deadline. Simultaneous boundary expiry is resolved + /// deterministically in favour of the total deadline. + async fn next_frame( + &self, + response: &mut OwnedResponse, + idle_deadline: Instant, + deadline: Instant, + ) -> VmResult>> { + if self.shared.stopping.load(Ordering::Acquire) { + return Err(VmError::HostError("SSE stream cancelled".to_string())); + } + let boundary = deadline.min(idle_deadline); + tokio::select! { + biased; + _ = self.shared.cancel.notified() => { + Err(VmError::HostError("SSE stream cancelled".to_string())) + } + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(boundary)) => { + if deadline <= idle_deadline { + Err(VmError::HostError(SSE_TOTAL_DEADLINE_ERROR.to_string())) + } else { + Err(VmError::HostError("SSE stream idle timeout".to_string())) + } + } + frame = response.next_frame() => frame, + } + } + + /// Publishes one item into the bounded FIFO with backpressure. The send is + /// bounded by cancel and the absolute total deadline, so a stalled + /// callback or full queue cannot extend the stream past its deadline. + /// Wakes the stream driver's waker so the VM re-polls and drains the item. + async fn publish(&self, item: Value, deadline: Instant) -> VmResult<()> { + if self.shared.stopping.load(Ordering::Acquire) { + return Err(VmError::HostError("SSE stream cancelled".to_string())); + } + let sender = &self.shared.items; + tokio::select! { + biased; + _ = self.shared.cancel.notified() => { + Err(VmError::HostError("SSE stream cancelled".to_string())) + } + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => { + Err(VmError::HostError(SSE_TOTAL_DEADLINE_ERROR.to_string())) + } + sent = sender.send(item) => { + sent.map_err(|_| VmError::HostError("SSE stream closed".to_string()))?; + self.shared.waker.wake(); + if self.shared.stopping.load(Ordering::Acquire) { + return Err(VmError::HostError("SSE stream cancelled".to_string())); + } + tokio::select! { + biased; + _ = self.shared.cancel.notified() => { + Err(VmError::HostError("SSE stream cancelled".to_string())) + } + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)) => { + Err(VmError::HostError(SSE_TOTAL_DEADLINE_ERROR.to_string())) + } + _ = self.shared.item_ack.notified() => Ok(()), + } + } + } + } +} + +fn runtime_block_on(future: F) -> VmResult { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| VmError::HostError(format!("SSE worker runtime build failed: {error}")))?; + Ok(runtime.block_on(future)) +} + +/// Stream driver for the SSE stream: the VM's async host polls this driver +/// through [`submit_callable_stream`] for each item, then invokes the script +/// callback and calls [`apply_action`](Self::apply_action) with the result. +struct SseStreamDriver { + shared: Arc, + /// Bounded FIFO receiver for items published by the worker. + receiver: mpsc::Receiver, + status: u16, + headers: Arc>, + url: String, + items: usize, + bytes_received: Arc, + /// The absolute total deadline; the driver enforces it in + /// [`apply_action`](Self::apply_action) so a slow callback cannot extend + /// the stream past its deadline. + deadline: Instant, + scope_operation: crate::vm::operation::OperationId, + resource: ResourceHandle, + termination: Option, +} + +struct SseTerminationState { + operation_done: bool, + resource_done: bool, + first_error: Option, +} + +impl SseStreamDriver { + fn summary(&self, outcome: &str) -> Value { + map_value(vec![ + ("outcome", Value::string(outcome)), + ("status", Value::Int(i64::from(self.status))), + ("headers", Value::Array(Arc::clone(&self.headers))), + ("url", Value::string(&self.url)), + ("items", Value::Int(self.items as i64)), + ( + "bytes_received", + Value::Int(self.bytes_received.load(Ordering::Acquire) as i64), + ), + ("bytes_sent", Value::Int(0)), + ]) + } +} + +impl HostStreamDriver for SseStreamDriver { + fn acknowledge_item(&mut self) { + self.shared.item_ack.notify_one(); + } + + fn terminate( + &mut self, + scope: &mut ExecutionScope, + termination: HostStreamTermination, + ) -> VmResult<()> { + self.begin_termination(scope, termination)?; + let waker = std::task::Waker::noop(); + let mut cx = Context::from_waker(waker); + match self.poll_termination(scope, termination, &mut cx) { + Poll::Ready(result) => result, + Poll::Pending => Err(VmError::HostError( + "SSE stream termination is still pending".to_string(), + )), + } + } + + fn begin_termination( + &mut self, + scope: &mut ExecutionScope, + termination: HostStreamTermination, + ) -> VmResult<()> { + if self.termination.is_some() { + return Ok(()); + } + if let HostStreamTermination::Cancelled(reason) = termination { + self.shared.request_stop(reason); + } + match termination { + HostStreamTermination::Completed => scope + .complete_operation(self.scope_operation) + .map_err(VmError::ExecutionScope)?, + HostStreamTermination::Cancelled(reason) => scope + .cancel_operation(self.scope_operation, reason) + .map_err(VmError::ExecutionScope)?, + }; + let resource_reason = sse_resource_close_reason(termination); + let resource_done = match scope + .close_resource::(self.resource, resource_reason) + .map_err(VmError::ExecutionScope)? + { + CloseProgress::Ready => true, + CloseProgress::Pending => false, + }; + self.termination = Some(SseTerminationState { + operation_done: false, + resource_done, + first_error: None, + }); + Ok(()) + } + + fn poll_termination( + &mut self, + scope: &mut ExecutionScope, + _termination: HostStreamTermination, + cx: &mut Context<'_>, + ) -> Poll> { + let Some(state) = self.termination.as_mut() else { + return Poll::Ready(Err(VmError::HostError( + "SSE stream termination was not started".to_string(), + ))); + }; + if !state.operation_done { + match scope.poll_operation_quiescence(self.scope_operation, cx) { + Poll::Pending => {} + Poll::Ready(Ok(_)) => state.operation_done = true, + Poll::Ready(Err(error)) => { + state.operation_done = true; + if state.first_error.is_none() { + state.first_error = Some(VmError::ExecutionScope(error)); + } + } + } + } + if !state.resource_done { + match scope.poll_resource_close::(self.resource, cx) { + Poll::Pending => {} + Poll::Ready(Ok(())) => state.resource_done = true, + Poll::Ready(Err(ExecutionScopeError::Resource(error))) + if error.code() == ResourceErrorCode::ResourceAlreadyClosed => + { + state.resource_done = true; + } + Poll::Ready(Err(error)) => { + state.resource_done = true; + if state.first_error.is_none() { + state.first_error = Some(VmError::ExecutionScope(error)); + } + } + } + } + if state.operation_done && state.resource_done { + let state = self.termination.take().expect("termination state exists"); + match state.first_error { + Some(error) => Poll::Ready(Err(error)), + None => Poll::Ready(Ok(())), + } + } else { + Poll::Pending + } + } + + fn poll_next(&mut self, cx: &mut Context<'_>) -> Poll> { + match self.receiver.poll_recv(cx) { + Poll::Ready(Some(item)) => { + // Track items and capture metadata from the open item. + if let Value::Map(ref map) = item { + match map.get(&Value::string("kind")) { + Some(Value::String(kind)) if kind.as_str() == "open" => { + if let Some(Value::Int(status)) = map.get(&Value::string("status")) { + self.status = *status as u16; + } + if let Some(Value::Array(headers)) = map.get(&Value::string("headers")) + { + self.headers = Arc::clone(headers); + } + if let Some(Value::String(url)) = map.get(&Value::string("url")) { + self.url = url.as_ref().clone(); + } + } + _ => {} + } + } + self.items = self.items.saturating_add(1); + Poll::Ready(Ok(HostStreamPoll::Item(item))) + } + Poll::Ready(None) => self.poll_terminal("eof"), + Poll::Pending => { + if self.shared.done.load(Ordering::Acquire) { + return self.poll_terminal(if self.shared.stopping.load(Ordering::Acquire) { + "stopped" + } else { + "eof" + }); + } + self.shared.waker.register(cx.waker()); + if self.shared.done.load(Ordering::Acquire) { + self.poll_terminal(if self.shared.stopping.load(Ordering::Acquire) { + "stopped" + } else { + "eof" + }) + } else { + // A stop is terminal only after the worker publishes its + // result. The stop notification wakes this poll through + // the atomic waker while the worker is still unwinding. + Poll::Pending + } + } + } + } + + fn apply_action(&mut self, action: Value) -> VmResult { + // The absolute total deadline is enforced here too: a slow callback + // (e.g. one awaiting a host future) must not extend the stream past + // its deadline. Once the deadline has passed, every callback action + // fails deterministically. + if Instant::now() >= self.deadline { + return Err(VmError::HostError(SSE_TOTAL_DEADLINE_ERROR.to_string())); + } + let Value::Map(action) = action else { + return Err(VmError::HostError( + "SSE callback action must be a map".to_string(), + )); + }; + let Some(Value::String(action)) = action.get(&Value::string("action")) else { + return Err(VmError::HostError( + "SSE callback action must contain string 'action'".to_string(), + )); + }; + match action.as_str() { + "continue" => Ok(HostStreamAction::Continue), + "stop" => Ok(HostStreamAction::Cancel( + self.summary("stopped"), + OperationCancelReason::Requested, + )), + other => Err(VmError::HostError(format!( + "invalid SSE callback action '{other}'" + ))), + } + } +} + +impl SseStreamDriver { + fn poll_terminal(&mut self, outcome: &str) -> Poll> { + match self.shared.take_result() { + Some(Ok(())) => Poll::Ready(Ok(HostStreamPoll::Complete(self.summary(outcome)))), + Some(Err(error)) => Poll::Ready(Err(error)), + None => Poll::Ready(Err(VmError::HostError( + "SSE worker completed without a terminal result".to_string(), + ))), + } + } +} + +fn sse_resource_close_reason(termination: HostStreamTermination) -> ResourceCloseReason { + match termination { + HostStreamTermination::Completed => ResourceCloseReason::ResourceClosed, + HostStreamTermination::Cancelled(reason) => match reason { + OperationCancelReason::Requested => ResourceCloseReason::Requested, + OperationCancelReason::Deadline => ResourceCloseReason::Deadline, + OperationCancelReason::VmReset => ResourceCloseReason::VmReset, + OperationCancelReason::Parent => ResourceCloseReason::Parent, + OperationCancelReason::ResourceClosed => ResourceCloseReason::ResourceClosed, + OperationCancelReason::VmDrop => ResourceCloseReason::VmDrop, + }, + } +} + +fn close_sse_resource(vm: &mut Vm, resource: ResourceHandle) -> VmResult<()> { + let progress = vm + .host_context() + .close_resource::(resource, ResourceCloseReason::ResourceClosed) + .map_err(|error| VmError::HostError(format!("failed to close SSE resource: {error}")))?; + match progress { + CloseProgress::Ready => Ok(()), + CloseProgress::Pending => Err(VmError::HostError( + "SSE resource close remained pending after producer retirement".to_string(), + )), + } +} + +fn rollback_sse_admission( + vm: &mut Vm, + shared: &Arc, + resource: ResourceHandle, + operation: Option, + primary: VmError, +) -> VmError { + if shared.rollback_finished.load(Ordering::Acquire) { + return primary; + } + let mut cleanup_errors = Vec::new(); + let _ = shared.terminalize_workerless( + OperationCancelReason::Requested, + Err(VmError::HostError("SSE worker was not started".to_string())), + ); + if let Some(operation) = operation + && let Err(error) = vm + .host_context() + .abort_operation(operation, OperationCancelReason::Requested) + { + cleanup_errors.push(VmError::HostError(format!( + "failed to abort SSE operation: {error}" + ))); + } + if let Err(error) = shared.join_worker() { + cleanup_errors.push(VmError::HostError(format!( + "failed to join SSE worker: {error}" + ))); + } + if let Err(error) = close_sse_resource(vm, resource) { + cleanup_errors.push(error); + } + if cleanup_errors.is_empty() { + shared.rollback_finished.store(true, Ordering::Release); + } + cleanup_errors + .into_iter() + .fold(primary, |primary, cleanup| { + crate::vm::async_host::preserve_stream_cleanup(primary, Err(cleanup)) + }) +} + +/// The SSE stream reader registered as a child resource in the execution +/// scope. Closing it via the scope lifecycle sets `stopping` on the shared +/// state, which the worker observes between items and stops promptly. +pub(crate) struct SseStreamResource { + shared: Arc, +} + +impl HostResource for SseStreamResource { + fn resource_type_key() -> Option { + ResourceTypeKey::new("http.sse").ok() + } + + fn begin_close(&mut self, reason: ResourceCloseReason) -> ResourceResult { + self.shared.request_stop(match reason { + ResourceCloseReason::Requested => OperationCancelReason::Requested, + ResourceCloseReason::Deadline => OperationCancelReason::Deadline, + ResourceCloseReason::VmReset => OperationCancelReason::VmReset, + ResourceCloseReason::Parent => OperationCancelReason::Parent, + ResourceCloseReason::ResourceClosed => OperationCancelReason::ResourceClosed, + ResourceCloseReason::VmDrop => OperationCancelReason::VmDrop, + }); + match self.shared.try_join_finished()? { + true => Ok(CloseProgress::Ready), + false => Ok(CloseProgress::Pending), + } + } + + fn poll_close(&mut self, cx: &mut Context<'_>) -> Poll> { + match self.shared.try_join_finished() { + Ok(true) => Poll::Ready(Ok(())), + Ok(false) => { + self.shared.close_waker.register(cx.waker()); + match self.shared.try_join_finished() { + Ok(true) => Poll::Ready(Ok(())), + Ok(false) => Poll::Pending, + Err(error) => Poll::Ready(Err(error)), + } + } + Err(error) => Poll::Ready(Err(error)), + } + } +} + +/// Scope operation that tracks the pending SSE network poll. Cancel sets +/// `stopping` on the shared state so the worker stops promptly. The actual +/// item delivery is driven by the `SseStreamDriver` through the callable +/// stream path; this operation exists only for scope lifecycle management. +pub(super) struct SseScopeOperation { + shared: Arc, +} + +impl HostOperation for SseScopeOperation { + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + if self.shared.done.load(Ordering::SeqCst) { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + + fn cancel(&mut self, reason: OperationCancelReason) -> OperationResult<()> { + self.shared.request_stop(reason); + Ok(()) + } + + fn cancel_and_wait(&mut self, reason: OperationCancelReason) -> OperationResult<()> { + self.shared.request_stop(reason); + if !self.shared.is_quiescent() { + return Err(OperationError::new( + OperationErrorCode::OperationDriverFailed, + "http::sse", + "SSE worker cancellation is still pending", + )); + } + self.shared.join_worker().map_err(|message| { + OperationError::new( + OperationErrorCode::OperationDriverFailed, + "http::sse", + message, + ) + }) + } + + fn is_quiescent(&self) -> bool { + self.shared.is_quiescent() + } + + fn register_quiescence_waker(&mut self, cx: &Context<'_>) { + self.shared.quiescence_waker.register(cx.waker()); + } + + fn poll_quiescent(&mut self, cx: &mut Context<'_>) -> Poll<()> { + if self.shared.is_quiescent() { + return Poll::Ready(()); + } + self.shared.waker.register(cx.waker()); + self.shared.close_waker.register(cx.waker()); + self.shared.quiescence_waker.register(cx.waker()); + if self.shared.is_quiescent() { + Poll::Ready(()) + } else { + let _ = self.shared.try_join_finished(); + if self.shared.is_quiescent() { + Poll::Ready(()) + } else { + Poll::Pending + } + } + } +} + +/// Streams one bounded SSE item into one script callback at a time. +#[pd_host_function(name = "http::client::sse")] +pub(super) fn builtin_http_client_sse( + vm: &mut Vm, + request: VmMapHandle, + on_event: VmCallable VmMap>, +) -> VmResult> { + let callback = on_event.into_value(); + vm.validate_sse_callback_value(&callback)?; + let script_timeout = parse_stream_timeout(&request)?; + let (context, deadline) = HttpRequestContext::capture(vm, script_timeout, "SSE")?; + let mut request = parse_request(&request, &context.config)?; + policy::validate_url_policy(&context.config, policy::SchemeFamily::Http, &request.url)?; + if request.method != hyper::Method::GET && request.method != hyper::Method::POST { + return Err(VmError::HostError( + "SSE requests require GET or POST".to_string(), + )); + } + if !request + .headers + .iter() + .any(|(name, _)| name == hyper::header::ACCEPT) + { + request.headers.push(( + hyper::header::ACCEPT, + hyper::header::HeaderValue::from_static("text/event-stream"), + )); + } + validate_request_header_budget(&request.headers, &context.config)?; + + let config = context.config.clone(); + let permit = context.into_permit(); + let (items, receiver) = mpsc::channel(SSE_CHANNEL_CAPACITY); + let shared = Arc::new(SseShared { + stopping: AtomicBool::new(false), + cancel: Notify::new(), + cancellation_reason: std::sync::Mutex::new(None), + item_ack: Notify::new(), + waker: AtomicWaker::new(), + items, + done: AtomicBool::new(false), + thread_finished: AtomicBool::new(false), + worker_lifecycle: std::sync::atomic::AtomicU8::new(SseWorkerLifecycle::NotStarted as u8), + result: std::sync::Mutex::new(None), + join_handle: std::sync::Mutex::new(None), + close_waker: AtomicWaker::new(), + quiescence_waker: AtomicWaker::new(), + _permit: permit, + rollback_finished: AtomicBool::new(false), + }); + + // The SSE stream itself is a typed scope resource. The underlying response + // is owned by the stream worker and is closed after producer quiescence. + let sse_token = vm + .host_context() + .push_resource(SseStreamResource { + shared: Arc::clone(&shared), + }) + .map_err(|error| { + VmError::HostError(format!("failed to push SSE child resource: {error}")) + })?; + let resource = sse_token.handle(); + let op = SseScopeOperation { + shared: Arc::clone(&shared), + }; + let scope_operation = match start_operation(vm, op) { + Ok(operation) => operation, + Err(error) => { + return Err(rollback_sse_admission( + vm, + &shared, + resource, + None, + VmError::HostError(format!("failed to start SSE operation: {error}")), + )); + } + }; + + let worker = Arc::new(SseWorker { + config: config.clone(), + request, + deadline, + shared: Arc::clone(&shared), + items: Arc::new(AtomicUsize::new(0)), + bytes_received: Arc::new(AtomicUsize::new(0)), + status: std::sync::Mutex::new(None), + headers: std::sync::Mutex::new(None), + url: std::sync::Mutex::new(None), + }); + let bytes_received = worker.bytes_received.clone(); + + let join_handle = match spawn_worker("rustscript-sse-worker", { + let worker_shared = Arc::clone(&shared); + move || { + worker_shared.mark_worker_running(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + worker.run(); + })); + if let Err(panic) = result { + worker_shared.publish(Err(VmError::HostError(format!( + "SSE worker panicked: {}", + worker_panic_message(&panic) + )))); + } + worker_shared.mark_worker_finished(); + worker_shared.waker.wake(); + } + }) { + Ok(handle) => handle, + Err(error) => { + return Err(rollback_sse_admission( + vm, + &shared, + resource, + Some(scope_operation), + VmError::HostError(format!("failed to start SSE worker: {error}")), + )); + } + }; + shared.mark_worker_running(); + *shared + .join_handle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(join_handle); + + let driver = SseStreamDriver { + shared: Arc::clone(&shared), + receiver, + status: 0, + headers: Arc::new(Vec::new()), + url: String::new(), + items: 0, + bytes_received, + deadline, + scope_operation, + resource, + termination: None, + }; + + match vm.submit_callable_stream(callback, driver) { + Ok(CallOutcome::Pending(op_id)) => Ok(HostCallResult::Pending(op_id)), + Ok(_) => Err(rollback_sse_admission( + vm, + &shared, + resource, + Some(scope_operation), + VmError::InvalidFrameState("callable stream admission returned a non-pending outcome"), + )), + Err(rejection) => Err(vm.rollback_rejected_callable_stream(rejection)), + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::{ + FAIL_NEXT_WORKER_SPAWN, REJECT_NEXT_OPERATION_ADMISSION, SseEvent, SseParser, + SseScopeOperation, SseShared, SseStreamResource, SseWorkerLifecycle, + rollback_sse_admission, spawn_worker, start_operation, + }; + use crate::vm::VmError; + + fn event(data: &str, event: Option<&str>, id: Option<&str>, retry_ms: Option) -> SseEvent { + SseEvent { + event: event.map(str::to_string), + data: data.to_string(), + id: id.map(str::to_string), + retry_ms, + } + } + + fn parse_fragments( + fragments: &[&[u8]], + line: usize, + item: usize, + total: usize, + ) -> Result, String> { + let mut parser = SseParser::new(line, item, total); + let mut events = Vec::new(); + for fragment in fragments { + events.extend(parser.push(fragment).map_err(|error| error.to_string())?); + } + events.extend(parser.finish().map_err(|error| error.to_string())?); + Ok(events) + } + + #[test] + fn parser_accepts_fragmented_bom_utf8_and_every_line_ending() { + let fragments: &[&[u8]] = &[ + b"\xef", + b"\xbb\xbfdata: h\xc3", + b"\xa9\r", + b"data: two\n", + b"event:first\r\nevent: final\r", + b"id: 7\nretry: 25\n\n", + ]; + assert_eq!( + parse_fragments(fragments, 64, 128, 256).unwrap(), + vec![event("hé\ntwo", Some("final"), Some("7"), Some(25))] + ); + } + + #[test] + fn parser_clears_event_type_at_empty_data_dispatch_boundary() { + assert_eq!( + parse_fragments( + &[b"event: custom\nid: 7\nretry: 25\n\ndata: payload\n\n"], + 64, + 128, + 256 + ) + .unwrap(), + vec![event("payload", None, Some("7"), Some(25))] + ); + } + + #[test] + fn parser_clears_fragmented_event_type_at_crlf_boundaries() { + let fragments: &[&[u8]] = &[ + b"event: custom\r", + b"\nid: 7\r\nretry: 25\r", + b"\n\r\ndata: pay", + b"load\r\n\r", + b"\nevent: named\r\ndata: second\r\n\r\n", + b"data: next\r\n\r\n", + ]; + assert_eq!( + parse_fragments(fragments, 64, 128, 256).unwrap(), + vec![ + event("payload", None, Some("7"), Some(25)), + event("second", Some("named"), Some("7"), Some(25)), + event("next", None, Some("7"), Some(25)), + ] + ); + } + + #[test] + fn parser_uses_first_colon_removes_one_space_and_ignores_comments_unknown_fields() { + let input = b": comment\ndata:a:b\ndata: two\ndata: \nunknown: value\n\n"; + assert_eq!( + parse_fragments(&[input], 64, 128, 256).unwrap(), + vec![event("a:b\n two\n", None, None, None)] + ); + } + + #[test] + fn parser_handles_empty_fields_id_nul_and_retry_rules() { + let input = b"id: keep\nretry: 42\ndata: one\n\nretry: 99\n\nid:\nid: bad\0id\nretry: -1\nretry: 4x\nretry: 9223372036854775808\nevent:\ndata: two\n\n"; + assert_eq!( + parse_fragments(&[input], 64, 128, 512).unwrap(), + vec![ + event("one", None, Some("keep"), Some(42)), + event("two", Some(""), Some(""), Some(99)), + ] + ); + } + + #[test] + fn parser_persists_retry_state_across_empty_blocks_events_and_invalid_values() { + let input = b"retry:5000\n\ndata:ready\n\ndata:next\n\nretry:\nretry: -1\nretry: 5x\nretry: 9223372036854775808\n\ndata:still\n\n"; + assert_eq!( + parse_fragments(&[input], 64, 128, 512).unwrap(), + vec![ + event("ready", None, None, Some(5000)), + event("next", None, None, Some(5000)), + event("still", None, None, Some(5000)), + ] + ); + } + + #[test] + fn parser_discards_incomplete_event_at_eof_and_ignores_field_only_blocks() { + assert!( + parse_fragments(&[b"event: named\nid: x\n\ndata: tail"], 64, 128, 256) + .unwrap() + .is_empty() + ); + assert_eq!( + parse_fragments(&[b"id: x\n\ndata: complete\n\n"], 64, 128, 256).unwrap(), + vec![event("complete", None, Some("x"), None)] + ); + assert!( + parse_fragments(&[b"event: unused"], 64, 128, 256) + .unwrap() + .is_empty() + ); + } + + #[test] + fn parser_rejects_malformed_and_incomplete_utf8() { + for input in [ + b"data: \xff\n\n".as_slice(), + b"data: \xc3".as_slice(), + // A BOM prefix that never completes is still invalid UTF-8 and + // must surface from `finish` at EOF instead of being dropped. + b"\xef".as_slice(), + b"\xef\xbb".as_slice(), + ] { + assert!( + parse_fragments(&[input], 64, 128, 256) + .unwrap_err() + .contains("UTF-8") + ); + } + } + + #[test] + fn parser_enforces_exact_line_item_and_total_boundaries() { + assert_eq!( + parse_fragments(&[b"data: ab\n\n"], 8, 2, 10).unwrap(), + vec![event("ab", None, None, None)] + ); + assert!( + parse_fragments(&[b"data: abc\n\n"], 8, 3, 12) + .unwrap_err() + .contains("line") + ); + assert!( + parse_fragments(&[b"data: ab\ndata: c\n\n"], 16, 3, 64) + .unwrap_err() + .contains("item") + ); + assert!( + parse_fragments(&[b"data: ab\n\n"], 8, 2, 9) + .unwrap_err() + .contains("total") + ); + } + + #[test] + fn parser_enforces_line_item_and_total_limits_across_one_byte_chunks() { + let exact = b"data: x\n\n"; + let exact_fragments: Vec<&[u8]> = exact.chunks(1).collect(); + assert_eq!( + parse_fragments(&exact_fragments, 7, 1, exact.len()).unwrap(), + vec![event("x", None, None, None)] + ); + + let line_over = b"data: abc\n\n"; + let line_fragments: Vec<&[u8]> = line_over.chunks(1).collect(); + assert!( + parse_fragments(&line_fragments, 8, 16, 64) + .unwrap_err() + .contains("line") + ); + + let item_over = b"data: ab\ndata: c\n\n"; + let item_fragments: Vec<&[u8]> = item_over.chunks(1).collect(); + assert!( + parse_fragments(&item_fragments, 16, 3, 64) + .unwrap_err() + .contains("item") + ); + + let total_over = b"data: ab\n\n"; + let total_fragments: Vec<&[u8]> = total_over.chunks(1).collect(); + assert!( + parse_fragments(&total_fragments, 16, 16, total_over.len() - 1) + .unwrap_err() + .contains("total") + ); + } + + #[test] + fn parser_rejects_a_single_fragment_before_unbounded_growth() { + let mut parser = SseParser::new(4, 16, 64); + assert!(parser.push(b"data: a very large fragment").is_err()); + } + + #[test] + fn parser_only_strips_a_bom_at_the_start_of_the_stream() { + assert_eq!( + parse_fragments( + &[b"data: first\n\ndata: \xef\xbb\xbfsecond\n\n"], + 64, + 128, + 256 + ) + .unwrap(), + vec![ + event("first", None, None, None), + event("\u{feff}second", None, None, None), + ] + ); + } + + #[test] + fn admission_rollback_reclaims_workerless_sse_resource() { + let mut vm = crate::vm::Vm::new(crate::vm::Program::new( + Vec::new(), + vec![crate::vm::OpCode::Ret as u8], + )); + let permit = crate::builtins::runtime::http::policy::ConnectionAdmission::new(1) + .acquire() + .expect("test permit"); + let (items, _receiver) = tokio::sync::mpsc::channel(1); + let shared = std::sync::Arc::new(SseShared { + stopping: AtomicBool::new(false), + cancel: tokio::sync::Notify::new(), + cancellation_reason: std::sync::Mutex::new(None), + item_ack: tokio::sync::Notify::new(), + waker: futures_util::task::AtomicWaker::new(), + items, + done: AtomicBool::new(false), + thread_finished: AtomicBool::new(false), + worker_lifecycle: std::sync::atomic::AtomicU8::new( + SseWorkerLifecycle::NotStarted as u8, + ), + result: std::sync::Mutex::new(None), + join_handle: std::sync::Mutex::new(None), + close_waker: futures_util::task::AtomicWaker::new(), + quiescence_waker: futures_util::task::AtomicWaker::new(), + _permit: permit, + rollback_finished: AtomicBool::new(false), + }); + let token = vm + .execution_scope() + .push_resource(SseStreamResource { + shared: std::sync::Arc::clone(&shared), + }) + .expect("SSE resource"); + let primary = crate::vm::VmError::HostError("operation admission rejected".to_string()); + REJECT_NEXT_OPERATION_ADMISSION.store(true, Ordering::Release); + let admission = start_operation( + &mut vm, + SseScopeOperation { + shared: std::sync::Arc::clone(&shared), + }, + ); + assert!(admission.is_err()); + + let error = rollback_sse_admission(&mut vm, &shared, token.handle(), None, primary); + + assert!(error.to_string().contains("operation admission rejected")); + assert_eq!(vm.execution_scope().resources().len(), 0); + assert_eq!( + shared.worker_lifecycle.load(Ordering::Acquire), + SseWorkerLifecycle::Finished as u8 + ); + assert!(shared.done.load(Ordering::Acquire)); + assert!(shared.thread_finished.load(Ordering::Acquire)); + + let repeated = rollback_sse_admission( + &mut vm, + &shared, + token.handle(), + None, + VmError::HostError("repeated rollback".to_string()), + ); + assert!(repeated.to_string().contains("repeated rollback")); + } + + #[test] + fn worker_spawn_abstraction_can_inject_a_builder_failure() { + FAIL_NEXT_WORKER_SPAWN.store(true, Ordering::Release); + let result = spawn_worker("injected-sse-worker", || {}); + let error = match result { + Ok(handle) => { + handle.join().expect("unexpected worker"); + panic!("spawn should have been rejected") + } + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("injected SSE worker spawn failure") + ); + } +} diff --git a/src/builtins/runtime/jit.rs b/src/builtins/runtime/jit.rs index bb7aac9c..f12e8c80 100644 --- a/src/builtins/runtime/jit.rs +++ b/src/builtins/runtime/jit.rs @@ -1,7 +1,20 @@ +use std::sync::{Arc, OnceLock}; + use pd_host_function::pd_host_function; -use super::VmMap; -use crate::vm::{Value, Vm, VmResult}; +use super::typed::VmMapHandle; +use super::{CallOutcome, FromVmValue, VmMap, return_one}; +use crate::host_api::{ + HostApiBuilder, HostApiCatalog, HostFunctionSchema, HostParamSchema, HostStructField, + HostStructSchema, HostTypeSchema, +}; +use crate::vm::{ + HostFunctionRegistry, Value, Vm, VmError, VmResult, catalog_named_struct_schemas, + host_extension, +}; + +const GET_CONFIG: &str = "jit::get_config"; +const SET_CONFIG: &str = "jit::set_config"; fn config_as_map(vm: &Vm) -> VmMap { let config = vm.jit_config(); @@ -16,23 +29,57 @@ fn config_as_map(vm: &Vm) -> VmMap { ]) } -/// Sets the JIT runtime configuration from a map. -#[pd_host_function(name = "jit::set_config")] -pub(super) fn builtin_jit_set_config( +fn map_field<'a, T: FromVmValue<'a>>(map: &'a VmMap, key: &str) -> VmResult { + let value = map + .get(&Value::string(key)) + .ok_or_else(|| VmError::HostError(format!("missing JIT config field '{key}'")))?; + T::from_vm_value(value, key) +} + +fn apply_jit_config( vm: &mut Vm, enabled: bool, hot_loop_threshold: u32, max_trace_len: usize, -) -> VmResult { +) -> VmMap { let mut config = *vm.jit_config(); config.enabled = enabled; config.hot_loop_threshold = hot_loop_threshold; config.max_trace_len = max_trace_len; vm.set_jit_config(config); - Ok(config_as_map(vm)) + config_as_map(vm) +} + +/// Sets the JIT runtime configuration from positional `enabled`, +/// `hot_loop_threshold`, and `max_trace_len` arguments. +#[pd_host_function(name = "jit::set_config")] +pub(super) fn builtin_jit_set_config( + vm: &mut Vm, + enabled: bool, + hot_loop_threshold: u32, + max_trace_len: usize, +) -> VmResult { + Ok(apply_jit_config( + vm, + enabled, + hot_loop_threshold, + max_trace_len, + )) } -/// Returns the current JIT runtime configuration as a map. +fn set_config_from_map(vm: &mut Vm, config: &VmMap) -> VmResult { + let enabled = map_field(config, "enabled")?; + let hot_loop_threshold = map_field(config, "hot_loop_threshold")?; + let max_trace_len = map_field(config, "max_trace_len")?; + Ok(apply_jit_config( + vm, + enabled, + hot_loop_threshold, + max_trace_len, + )) +} + +/// Returns the current JIT runtime configuration as a `JitConfig` map. #[pd_host_function(name = "jit::get_config")] pub(super) fn builtin_jit_get_config(vm: &mut Vm) -> VmResult { Ok(config_as_map(vm)) @@ -85,3 +132,191 @@ pub(super) fn builtin_jit_set_max_trace_len(vm: &mut Vm, max_trace_len: usize) - pub(super) fn builtin_jit_get_max_trace_len(vm: &mut Vm) -> VmResult { Ok(vm.jit_config().max_trace_len) } + +fn jit_config_struct() -> HostStructSchema { + HostStructSchema::new( + "JitConfig", + vec![ + HostStructField::new("enabled", HostTypeSchema::Bool), + HostStructField::new("hot_loop_threshold", HostTypeSchema::Int), + HostStructField::new("max_trace_len", HostTypeSchema::Int), + ], + ) + .with_description("JIT runtime configuration.") +} + +fn build_jit_host_catalog() -> HostApiCatalog { + let config = jit_config_struct(); + let config_ty = config.as_type(); + let mut builder = HostApiBuilder::new(); + builder.named_struct(config); + builder.function( + HostFunctionSchema::with_return(GET_CONFIG, vec![], config_ty.clone()) + .with_description("Returns the current JIT runtime configuration."), + ); + builder.function( + HostFunctionSchema::with_return( + SET_CONFIG, + vec![HostParamSchema::value("config", config_ty.clone())], + config_ty.clone(), + ) + .with_description("Sets the JIT runtime configuration from a JitConfig value."), + ); + builder.function( + HostFunctionSchema::with_return( + SET_CONFIG, + vec![ + HostParamSchema::value("enabled", HostTypeSchema::Bool), + HostParamSchema::value("hot_loop_threshold", HostTypeSchema::Int), + HostParamSchema::value("max_trace_len", HostTypeSchema::Int), + ], + config_ty, + ) + .with_description( + "Sets the JIT runtime configuration from enabled, hot_loop_threshold, and max_trace_len.", + ), + ); + builder.build().expect("JIT host catalog must be valid") +} + +static JIT_HOST_CATALOG: OnceLock> = OnceLock::new(); + +/// JIT host catalog: `jit::get_config` returns named `JitConfig`; +/// `jit::set_config` accepts that struct or the positional `(bool, int, int)` +/// overload. +/// +/// Runtime values remain maps. Other `jit::*` members stay namespaced builtins. +pub fn jit_host_catalog() -> Arc { + Arc::clone(JIT_HOST_CATALOG.get_or_init(|| Arc::new(build_jit_host_catalog()))) +} + +struct JitAdapterContract { + name: &'static str, + arity: u8, + adapter: fn(&mut Vm, &[Value]) -> VmResult, +} + +const JIT_ADAPTER_CONTRACTS: &[JitAdapterContract] = &[ + JitAdapterContract { + name: GET_CONFIG, + arity: 0, + adapter: get_config_adapter, + }, + JitAdapterContract { + name: SET_CONFIG, + arity: 1, + adapter: set_config_named_adapter, + }, + JitAdapterContract { + name: SET_CONFIG, + arity: 3, + adapter: set_config_positional_adapter, + }, +]; + +fn get_config_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + let map = builtin_jit_get_config(vm, args)?; + Ok(CallOutcome::Return(return_one(map))) +} + +fn set_config_named_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + let config = args + .first() + .ok_or_else(|| VmError::HostError("missing argument: config".to_string()))?; + let handle = VmMapHandle::from_vm_value(config, "config")?; + let map = set_config_from_map(vm, handle.as_ref())?; + Ok(CallOutcome::Return(return_one(map))) +} + +fn set_config_positional_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + let map = builtin_jit_set_config(vm, args)?; + Ok(CallOutcome::Return(return_one(map))) +} + +/// Registers `jit::get_config` / `jit::set_config` from [`standard_host_catalog`]. +pub fn register_jit_builtin_module(registry: &mut HostFunctionRegistry) -> VmResult<()> { + let catalog = crate::builtins::runtime::standard_host_catalog(); + register_jit_builtin_module_from_catalog(registry, catalog.as_ref()) +} + +/// Registers JIT config functions using schemas from `catalog`. +/// +/// `catalog` must declare the same `JitConfig` shape and `jit::get_config` / +/// `jit::set_config` overloads as [`jit_host_catalog`]; registered fingerprints +/// match the supplied catalog so exact compile/bind pairs. +pub fn register_jit_builtin_module_from_catalog( + registry: &mut HostFunctionRegistry, + catalog: &HostApiCatalog, +) -> VmResult<()> { + let contract = jit_host_catalog(); + let catalog_fingerprint = catalog.fingerprint(); + let contract_fingerprint = contract.fingerprint(); + let mut seen = Vec::<&'static str>::new(); + let schemas = JIT_ADAPTER_CONTRACTS + .iter() + .map(|entry| { + if seen.contains(&entry.name) { + return Ok((entry, Vec::new())); + } + seen.push(entry.name); + host_extension::validate_catalog_import_schemas_with_fingerprints( + catalog, + &contract, + entry.name, + catalog_fingerprint, + contract_fingerprint, + ) + .map(|schemas| (entry, schemas)) + }) + .collect::>>()?; + + registry.transactionally(|staged| { + staged.install_named_struct_schemas(catalog_named_struct_schemas(catalog))?; + for (entry, schemas) in &schemas { + if schemas.is_empty() { + continue; + } + for schema in schemas.iter().cloned() { + let Some(matching) = JIT_ADAPTER_CONTRACTS.iter().find(|contract| { + contract.name == entry.name + && usize::from(contract.arity) == schema.params.len() + }) else { + return Err(VmError::HostError(format!( + "missing JIT adapter for {} arity {}", + entry.name, + schema.params.len() + ))); + }; + staged.register_exact_static( + matching.name, + matching.arity, + schema, + matching.adapter, + )?; + } + staged.authorize_registered_builtin_import(entry.name); + } + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{OpCode, Program}; + + #[test] + fn named_set_config_is_atomic_on_missing_field() { + let mut vm = Vm::try_new(Program::new(Vec::new(), vec![OpCode::Ret as u8])) + .expect("test VM construction must not fail"); + let original = *vm.jit_config(); + let map = VmMap::from_entries(vec![(Value::string("enabled"), Value::Bool(true))]); + let err = set_config_from_map(&mut vm, &map).expect_err("missing fields must fail"); + assert!( + err.to_string().contains("hot_loop_threshold") + || err.to_string().contains("missing JIT config field"), + "{err}" + ); + assert_eq!(*vm.jit_config(), original); + } +} diff --git a/src/builtins/runtime/mod.rs b/src/builtins/runtime/mod.rs index fb12ea57..b0b89ba2 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -5,10 +5,11 @@ use std::sync::{Arc, OnceLock}; use crate::builtins::BuiltinFunction; use crate::host_api::{ - HostApiBuilder, HostApiCatalog, HostFunctionSchema, HostParamPassing, HostParamSchema, - HostTypeSchema, ResourceTypeKey, ResourceTypeSchema, + HostApiBuilder, HostApiCatalog, HostApiFingerprint, HostFunctionSchema, HostParamPassing, + HostParamSchema, HostStructField, HostStructSchema, HostTypeSchema, ResourceTypeKey, + ResourceTypeSchema, }; -#[cfg(feature = "async")] +#[cfg(all(feature = "async", not(target_family = "wasm")))] use crate::vm::CaptureAsyncHostContext; #[allow(unused_imports)] use crate::vm::{CallOutcome, CallReturn, HostOpId, Value, Vm, VmError, VmResult}; @@ -21,6 +22,8 @@ pub(crate) mod core; pub(crate) mod error; pub(crate) mod event; mod host; +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +pub(crate) mod http; #[cfg(not(target_arch = "wasm32"))] mod io; #[cfg(target_arch = "wasm32")] @@ -36,6 +39,12 @@ pub(crate) mod sqlite; pub(crate) mod standard_composition; mod typed; +pub use jit::{ + jit_host_catalog, register_jit_builtin_module, register_jit_builtin_module_from_catalog, +}; +#[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] +pub use sqlite::{register_sqlite_builtin_module, register_sqlite_builtin_module_from_catalog}; + /// Returns the editor/compiler catalog for the built-in host extensions. /// /// The runtime implementation and the semantic catalog intentionally share only @@ -80,47 +89,217 @@ pub fn io_host_catalog() -> Arc { })) } +fn optional_type(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Optional(Box::new(inner)) +} + +fn array_type(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Array(Box::new(inner)) +} + +fn sqlite_limits_struct() -> HostStructSchema { + HostStructSchema::new( + "SqliteLimits", + [ + "max_connections", + "max_statements", + "max_rows", + "max_columns", + "max_result_bytes", + "max_statement_bytes", + "max_parameters", + "max_parameter_bytes", + "max_pending_operations", + "max_transaction_ms", + "busy_timeout_ms", + ] + .into_iter() + .map(|name| HostStructField::new(name, optional_type(HostTypeSchema::Int))) + .collect(), + ) + .with_description("Effective SQLite host limits. Omitted keys keep the embedding ceiling.") +} + +fn sqlite_open_options_struct(limits: &HostStructSchema) -> HostStructSchema { + HostStructSchema::new( + "SqliteOpenOptions", + vec![ + HostStructField::new("path", optional_type(HostTypeSchema::String)), + HostStructField::new("mode", optional_type(HostTypeSchema::String)), + HostStructField::new("root", optional_type(HostTypeSchema::String)), + HostStructField::new("limits", optional_type(limits.as_type())), + ], + ) + .with_description("SQLite open options. Runtime still requires a non-empty path.") +} + +fn sqlite_execute_result_struct() -> HostStructSchema { + HostStructSchema::new( + "SqliteExecuteResult", + vec![ + HostStructField::new("rows_affected", HostTypeSchema::Int), + HostStructField::new("last_insert_rowid", HostTypeSchema::Int), + ], + ) + .with_description("Result envelope for sqlite::execute. Runtime value remains a map.") +} + +fn sqlite_value_struct() -> HostStructSchema { + HostStructSchema::new( + "SqliteValue", + vec![ + HostStructField::new("kind", HostTypeSchema::String), + HostStructField::new("int_value", optional_type(HostTypeSchema::Int)), + HostStructField::new("float_value", optional_type(HostTypeSchema::Float)), + HostStructField::new("text_value", optional_type(HostTypeSchema::String)), + HostStructField::new("blob_value", optional_type(HostTypeSchema::Bytes)), + ], + ) + .with_description( + "A tagged SQLite parameter or result cell. Exactly one payload matches kind, except null.", + ) +} + +fn sqlite_row_struct(value: &HostStructSchema) -> HostStructSchema { + HostStructSchema::new( + "SqliteRow", + vec![HostStructField::new("cells", array_type(value.as_type()))], + ) + .with_description("One SQLite result row containing typed cells in column order.") +} + +fn sqlite_query_result_struct(row: &HostStructSchema) -> HostStructSchema { + HostStructSchema::new( + "SqliteQueryResult", + vec![ + HostStructField::new("columns", array_type(HostTypeSchema::String)), + HostStructField::new("rows", array_type(row.as_type())), + HostStructField::new("truncated", HostTypeSchema::Bool), + HostStructField::new("next_cursor", optional_type(HostTypeSchema::Int)), + ], + ) + .with_description("Query result envelope with typed rows; next_cursor is omitted when absent.") +} + +fn sqlite_statement_struct( + limits: &HostStructSchema, + value: &HostStructSchema, +) -> HostStructSchema { + HostStructSchema::new( + "SqliteStatement", + vec![ + HostStructField::new("sql", HostTypeSchema::String), + HostStructField::new("params", optional_type(array_type(value.as_type()))), + HostStructField::new("query", optional_type(HostTypeSchema::Bool)), + HostStructField::new("limits", optional_type(limits.as_type())), + ], + ) + .with_description("One sqlite::transaction statement with optional typed parameters.") +} + +fn sqlite_transaction_result_struct( + execute: &HostStructSchema, + query: &HostStructSchema, +) -> HostStructSchema { + HostStructSchema::new( + "SqliteTransactionResult", + vec![ + HostStructField::new("kind", HostTypeSchema::String), + HostStructField::new("execute", optional_type(execute.as_type())), + HostStructField::new("query", optional_type(query.as_type())), + ], + ) + .with_description("A tagged ordered SQLite transaction result envelope.") +} + /// Returns the editor/compiler catalog for the SQLite host extension. pub fn sqlite_host_catalog() -> Arc { static CATALOG: OnceLock> = OnceLock::new(); - Arc::clone(CATALOG.get_or_init(|| { - let connection_key = - ResourceTypeKey::new("sqlite.connection").expect("built-in resource key is valid"); - let mut builder = HostApiBuilder::new(); - builder.resource(ResourceTypeSchema::new( - connection_key.clone(), - "An open SQLite connection", - )); - builder.function(HostFunctionSchema::with_return( - "sqlite::open", - vec![HostParamSchema::value("options", HostTypeSchema::Unknown)], - HostTypeSchema::Resource(connection_key.clone()), - )); - builder.function(HostFunctionSchema::with_return( - "sqlite::query", - vec![ - HostParamSchema::with_passing( - "connection", - HostTypeSchema::Resource(connection_key.clone()), - HostParamPassing::Borrow, - ), - HostParamSchema::value("sql", HostTypeSchema::String), - HostParamSchema::value("params", HostTypeSchema::Unknown), - HostParamSchema::value("options", HostTypeSchema::Unknown), - ], - HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)), - )); - builder.function(HostFunctionSchema::with_return( - "sqlite::close", - vec![HostParamSchema::with_passing( + Arc::clone(CATALOG.get_or_init(build_sqlite_host_catalog)) +} + +fn build_sqlite_host_catalog() -> Arc { + let connection_key = + ResourceTypeKey::new("sqlite.connection").expect("built-in resource key is valid"); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new( + connection_key.clone(), + "An open SQLite connection", + )); + + let limits = sqlite_limits_struct(); + let open_options = sqlite_open_options_struct(&limits); + let execute_result = sqlite_execute_result_struct(); + let value = sqlite_value_struct(); + let row = sqlite_row_struct(&value); + let query_result = sqlite_query_result_struct(&row); + let statement = sqlite_statement_struct(&limits, &value); + let transaction_result = sqlite_transaction_result_struct(&execute_result, &query_result); + builder.named_struct(limits.clone()); + builder.named_struct(open_options.clone()); + builder.named_struct(execute_result.clone()); + builder.named_struct(value.clone()); + builder.named_struct(row.clone()); + builder.named_struct(query_result.clone()); + builder.named_struct(statement.clone()); + builder.named_struct(transaction_result.clone()); + + // Positional params, result cells, and mixed transaction outputs use named + // structs. Runtime values remain map/array carriers for these named types. + builder.function(HostFunctionSchema::with_return( + "sqlite::open", + vec![HostParamSchema::value("options", open_options.as_type())], + HostTypeSchema::Resource(connection_key.clone()), + )); + builder.function(HostFunctionSchema::with_return( + "sqlite::execute", + vec![ + HostParamSchema::with_passing( "connection", - HostTypeSchema::Resource(connection_key), - HostParamPassing::TakeOwned, - )], - HostTypeSchema::Null, - )); - Arc::new(builder.build().expect("built-in SQLite catalog is valid")) - })) + HostTypeSchema::Resource(connection_key.clone()), + HostParamPassing::Borrow, + ), + HostParamSchema::value("sql", HostTypeSchema::String), + HostParamSchema::value("params", array_type(value.as_type())), + ], + execute_result.as_type(), + )); + builder.function(HostFunctionSchema::with_return( + "sqlite::query", + vec![ + HostParamSchema::with_passing( + "connection", + HostTypeSchema::Resource(connection_key.clone()), + HostParamPassing::Borrow, + ), + HostParamSchema::value("sql", HostTypeSchema::String), + HostParamSchema::value("params", array_type(value.as_type())), + HostParamSchema::value("limits", limits.as_type()), + ], + query_result.as_type(), + )); + builder.function(HostFunctionSchema::with_return( + "sqlite::transaction", + vec![ + HostParamSchema::with_passing( + "connection", + HostTypeSchema::Resource(connection_key.clone()), + HostParamPassing::Borrow, + ), + HostParamSchema::value("statements", array_type(statement.as_type())), + ], + array_type(transaction_result.as_type()), + )); + builder.function(HostFunctionSchema::with_return( + "sqlite::close", + vec![HostParamSchema::with_passing( + "connection", + HostTypeSchema::Resource(connection_key), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Null, + )); + Arc::new(builder.build().expect("built-in SQLite catalog is valid")) } /// Returns the combined catalog used by default source analysis. @@ -165,38 +344,46 @@ pub fn standard_host_catalog() -> Arc { )], HostTypeSchema::Bool, )); - builder.function(HostFunctionSchema::with_return( - "sqlite::open", - vec![HostParamSchema::value("options", HostTypeSchema::Unknown)], - HostTypeSchema::Resource(connection_key.clone()), - )); - builder.function(HostFunctionSchema::with_return( - "sqlite::query", - vec![ - HostParamSchema::with_passing( - "connection", - HostTypeSchema::Resource(connection_key.clone()), - HostParamPassing::Borrow, - ), - HostParamSchema::value("sql", HostTypeSchema::String), - HostParamSchema::value("params", HostTypeSchema::Unknown), - HostParamSchema::value("options", HostTypeSchema::Unknown), - ], - HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)), - )); - builder.function(HostFunctionSchema::with_return( - "sqlite::close", - vec![HostParamSchema::with_passing( - "connection", - HostTypeSchema::Resource(connection_key), - HostParamPassing::TakeOwned, - )], - HostTypeSchema::Null, - )); + { + let sqlite_catalog = sqlite_host_catalog(); + for schema in sqlite_catalog.structs() { + builder.named_struct(schema.clone()); + } + for function in sqlite_catalog.functions() { + builder.function(function.clone()); + } + } + #[cfg(all(feature = "http-client", not(target_family = "wasm")))] + { + let http_catalog = http::http_host_catalog(); + for resource in http_catalog.resources() { + builder.resource(resource.clone()); + } + for schema in http_catalog.structs() { + builder.named_struct(schema.clone()); + } + for function in http_catalog.functions() { + builder.function(function.clone()); + } + } + { + let jit_catalog = jit_host_catalog(); + for schema in jit_catalog.structs() { + builder.named_struct(schema.clone()); + } + for function in jit_catalog.functions() { + builder.function(function.clone()); + } + } Arc::new(builder.build().expect("standard host catalog is valid")) })) } +/// Returns the cached fingerprint for [`standard_host_catalog`]. +pub fn standard_host_catalog_fingerprint() -> HostApiFingerprint { + standard_host_catalog().fingerprint() +} + #[cfg(target_arch = "wasm32")] use io_wasm as io; diff --git a/src/builtins/runtime/sqlite.rs b/src/builtins/runtime/sqlite.rs index d89451a3..b7841d96 100644 --- a/src/builtins/runtime/sqlite.rs +++ b/src/builtins/runtime/sqlite.rs @@ -37,7 +37,8 @@ use rusqlite::types::{Value as SqlValue, ValueRef}; use rusqlite::{Connection, OpenFlags, TransactionBehavior, params_from_iter}; use super::typed::{VmArrayRef, VmMapRef}; -use super::{HostCallResult, VmMap}; +use super::{HostCallResult, IntoHostCallOutcome, VmMap}; +use crate::host_api::{HostApiCatalog, ResourceTypeKey}; use crate::vm::operation::driver::HostOperation; use crate::vm::operation::error::{OperationError, OperationErrorCode, OperationResult}; use crate::vm::operation::reason::OperationCancelReason; @@ -45,7 +46,10 @@ use crate::vm::operation::{OperationId, OperationOutcome, OperationSpec}; use crate::vm::resource::close::{CloseProgress, HostResource}; use crate::vm::resource::error::ResourceResult; use crate::vm::resource::{ResourceCloseReason, ResourceHandle}; -use crate::vm::{CallReturn, HostOpId, Value, Vm, VmError, VmResult}; +use crate::vm::{ + CallOutcome, CallReturn, HostFunctionRegistry, HostOpId, Value, Vm, VmError, VmResult, + catalog_named_struct_schemas, host_extension, +}; /// SQLite `progress_handler` step cadence used to surface cancellation while a /// statement runs. @@ -223,6 +227,10 @@ impl SqliteResource { } impl HostResource for SqliteResource { + fn resource_type_key() -> Option { + ResourceTypeKey::new("sqlite.connection").ok() + } + fn begin_close(&mut self, _reason: ResourceCloseReason) -> ResourceResult { if !self.slot.closed.swap(true, Ordering::AcqRel) { self.slot.interrupt.interrupt(); @@ -635,8 +643,8 @@ fn required_string(map: &VmMap, key: &str) -> VmResult { Some(Value::String(_)) => Err(VmError::HostError(format!( "SQLite {key} must not be empty" ))), + Some(Value::Null) | None => Err(VmError::HostError(format!("missing SQLite {key}"))), Some(_) => Err(VmError::TypeMismatch("SQLite option string")), - None => Err(VmError::HostError(format!("missing SQLite {key}"))), } } @@ -676,6 +684,9 @@ fn parse_limits(value: Option<&Value>, ceiling: SqliteLimits) -> VmResult, ceiling: SqliteLimits) -> VmResult { limits.max_connections = @@ -995,41 +1009,135 @@ fn validate_sql(sql: &str, limits: SqliteLimits, allow_unsafe_sql: bool) -> VmRe Ok(()) } -fn sqlite_params(values: VmArrayRef<'_>, limits: SqliteLimits) -> VmResult> { - if values.len() > limits.max_parameters { +fn reject_unexpected_fields(map: &VmMap, allowed: &[&str], context: &'static str) -> VmResult<()> { + for (key, _) in map { + let Value::String(key) = key else { + return Err(VmError::TypeMismatch(context)); + }; + if !allowed.iter().any(|allowed| *allowed == key.as_str()) { + return Err(VmError::HostError(format!( + "{context} contains unknown field '{key}'" + ))); + } + } + Ok(()) +} + +fn present_payload<'a>(map: &'a VmMap, key: &str) -> Option<&'a Value> { + match map_value(map, key) { + Some(Value::Null) | None => None, + Some(value) => Some(value), + } +} + +fn sqlite_parameter_value( + value: &Value, + parameter_bytes: &mut usize, + limits: SqliteLimits, +) -> VmResult { + let Value::Map(map) = value else { + return Err(VmError::TypeMismatch("SQLite value")); + }; + reject_unexpected_fields( + map, + &[ + "kind", + "int_value", + "float_value", + "text_value", + "blob_value", + ], + "SQLite value", + )?; + let kind = match map_value(map, "kind") { + Some(Value::String(kind)) => kind.as_str(), + Some(Value::Null) | None => { + return Err(VmError::HostError("missing SQLite value kind".to_string())); + } + Some(_) => return Err(VmError::TypeMismatch("SQLite value kind")), + }; + let payload_count = ["int_value", "float_value", "text_value", "blob_value"] + .into_iter() + .filter(|key| present_payload(map, key).is_some()) + .count(); + if payload_count > 1 { return Err(VmError::HostError( - "SQLite parameter count exceeds the configured limit".to_string(), + "SQLite value has multiple non-null payloads".to_string(), )); } - let mut bytes = 0usize; - let mut params = Vec::with_capacity(values.len()); - for value in values { - let sql_value = match value { - Value::Null => SqlValue::Null, - Value::Int(value) => SqlValue::Integer(*value), - Value::Float(value) => SqlValue::Real(*value), - Value::String(value) => { - bytes = bytes.saturating_add(value.len()); - SqlValue::Text(value.as_ref().clone()) - } - Value::Bytes(value) => { - bytes = bytes.saturating_add(value.len()); - SqlValue::Blob(value.as_ref().clone()) - } - _ => { + let selected_payload = match kind { + "null" => { + if payload_count != 0 { return Err(VmError::HostError( - "SQLite parameters support only null, int, float, string, and bytes" - .to_string(), + "SQLite null value must not have a payload".to_string(), )); } - }; - if bytes > limits.max_parameter_bytes { + return Ok(SqlValue::Null); + } + "int" => present_payload(map, "int_value") + .ok_or_else(|| VmError::HostError("missing SQLite int_value".to_string()))?, + "float" => present_payload(map, "float_value") + .ok_or_else(|| VmError::HostError("missing SQLite float_value".to_string()))?, + "text" => present_payload(map, "text_value") + .ok_or_else(|| VmError::HostError("missing SQLite text_value".to_string()))?, + "blob" => present_payload(map, "blob_value") + .ok_or_else(|| VmError::HostError("missing SQLite blob_value".to_string()))?, + _ => { return Err(VmError::HostError(format!( - "SQLite parameters exceed the configured {} byte limit", - limits.max_parameter_bytes + "unknown SQLite value kind '{kind}'" ))); } - params.push(sql_value); + }; + match kind { + "int" => match selected_payload { + Value::Int(value) => Ok(SqlValue::Integer(*value)), + _ => Err(VmError::TypeMismatch("SQLite int_value payload")), + }, + "float" => match selected_payload { + Value::Float(value) => Ok(SqlValue::Real(*value)), + _ => Err(VmError::TypeMismatch("SQLite float_value payload")), + }, + "text" => match selected_payload { + Value::String(value) => { + *parameter_bytes = parameter_bytes.saturating_add(value.len()); + if *parameter_bytes > limits.max_parameter_bytes { + return Err(VmError::HostError(format!( + "SQLite parameters exceed the configured {} byte limit", + limits.max_parameter_bytes + ))); + } + Ok(SqlValue::Text(value.as_ref().clone())) + } + _ => Err(VmError::TypeMismatch("SQLite text_value payload")), + }, + "blob" => match selected_payload { + Value::Bytes(value) => { + *parameter_bytes = parameter_bytes.saturating_add(value.len()); + if *parameter_bytes > limits.max_parameter_bytes { + return Err(VmError::HostError(format!( + "SQLite parameters exceed the configured {} byte limit", + limits.max_parameter_bytes + ))); + } + Ok(SqlValue::Blob(value.as_ref().clone())) + } + _ => Err(VmError::TypeMismatch("SQLite blob_value payload")), + }, + "null" => unreachable!("null SQLite values return before payload decoding"), + _ => unreachable!("unknown SQLite value kinds return before payload decoding"), + } +} + +fn sqlite_params(values: VmArrayRef<'_>, limits: SqliteLimits) -> VmResult> { + if values.len() > limits.max_parameters { + return Err(VmError::HostError( + "SQLite parameter count exceeds the configured limit".to_string(), + )); + } + let mut bytes = 0usize; + let mut params = Vec::with_capacity(values.len()); + for value in values { + params.push(sqlite_parameter_value(value, &mut bytes, limits)?); } Ok(params) } @@ -1082,16 +1190,77 @@ fn estimate_value_bytes(value: &Value) -> usize { } } -fn value_from_row(row: &rusqlite::Row<'_>, index: usize) -> Result { +fn sqlite_value_map( + kind: &str, + int_value: Option, + float_value: Option, + text_value: Option, + blob_value: Option>, +) -> Value { + Value::Map(Arc::new(VmMap::from_entries(vec![ + (Value::string("kind"), Value::string(kind)), + ( + Value::string("int_value"), + int_value.map_or(Value::Null, Value::Int), + ), + ( + Value::string("float_value"), + float_value.map_or(Value::Null, Value::Float), + ), + ( + Value::string("text_value"), + text_value.map_or(Value::Null, Value::string), + ), + ( + Value::string("blob_value"), + blob_value.map_or(Value::Null, Value::bytes), + ), + ]))) +} + +struct EncodedSqliteValue { + value: Value, + bytes: usize, + integer: Option, +} + +fn value_from_row( + row: &rusqlite::Row<'_>, + index: usize, +) -> Result { match row.get_ref(index)? { - ValueRef::Null => Ok(Value::Null), - ValueRef::Integer(value) => Ok(Value::Int(value)), - ValueRef::Real(value) => Ok(Value::Float(value)), + ValueRef::Null => Ok(EncodedSqliteValue { + value: sqlite_value_map("null", None, None, None, None), + bytes: 1, + integer: None, + }), + ValueRef::Integer(value) => Ok(EncodedSqliteValue { + value: sqlite_value_map("int", Some(value), None, None, None), + bytes: 8, + integer: Some(value), + }), + ValueRef::Real(value) => Ok(EncodedSqliteValue { + value: sqlite_value_map("float", None, Some(value), None, None), + bytes: 8, + integer: None, + }), ValueRef::Text(value) => match std::str::from_utf8(value) { - Ok(value) => Ok(Value::string(value)), - Err(_) => Ok(Value::bytes(value.to_vec())), + Ok(value) => Ok(EncodedSqliteValue { + value: sqlite_value_map("text", None, None, Some(value.to_string()), None), + bytes: value.len(), + integer: None, + }), + Err(_) => Ok(EncodedSqliteValue { + value: sqlite_value_map("blob", None, None, None, Some(value.to_vec())), + bytes: value.len(), + integer: None, + }), }, - ValueRef::Blob(value) => Ok(Value::bytes(value.to_vec())), + ValueRef::Blob(value) => Ok(EncodedSqliteValue { + value: sqlite_value_map("blob", None, None, None, Some(value.to_vec())), + bytes: value.len(), + integer: None, + }), } } @@ -1123,29 +1292,40 @@ fn query_with_connection( } let mut cells = Vec::with_capacity(column_count); let mut row_bytes = 0usize; + let mut row_cursor = None; for index in 0..column_count { - let value = value_from_row(row, index)?; - row_bytes = row_bytes.saturating_add(estimate_value_bytes(&value)); - cells.push(value); + let encoded = value_from_row(row, index)?; + row_bytes = row_bytes.saturating_add(encoded.bytes); + if index == 0 { + row_cursor = encoded.integer; + } + cells.push(encoded.value); } if result_bytes.saturating_add(row_bytes) > limits.max_result_bytes { truncated = true; break; } - if let Some(Value::Int(cursor)) = cells.first() { - next_cursor = Some(*cursor); + if let Some(cursor) = row_cursor { + next_cursor = Some(cursor); } result_bytes = result_bytes.saturating_add(row_bytes); - values.push(Value::array(cells)); + values.push(Value::Map(Arc::new(VmMap::from_entries(vec![( + Value::string("cells"), + Value::array(cells), + )])))); } let mut entries = vec![ (Value::string("columns"), Value::array(columns)), (Value::string("rows"), Value::array(values)), (Value::string("truncated"), Value::Bool(truncated)), ]; - if let Some(next_cursor) = next_cursor { - entries.push((Value::string("next_cursor"), Value::Int(next_cursor))); - } + entries.push(( + Value::string("next_cursor"), + match next_cursor { + Some(next_cursor) => Value::Int(next_cursor), + None => Value::Null, + }, + )); Ok(VmMap::from_entries(entries)) } @@ -1169,6 +1349,20 @@ fn execute_with_connection( ])) } +fn transaction_result_value(kind: &str, execute: Option, query: Option) -> Value { + Value::Map(Arc::new(VmMap::from_entries(vec![ + (Value::string("kind"), Value::string(kind)), + ( + Value::string("execute"), + execute.map_or(Value::Null, |value| Value::Map(Arc::new(value))), + ), + ( + Value::string("query"), + query.map_or(Value::Null, |value| Value::Map(Arc::new(value))), + ), + ]))) +} + struct SqliteWorkerCompletion { slot: Arc, shared: Arc, @@ -1453,18 +1647,18 @@ fn parse_transaction_statements( validate_sql(&sql, limits, allow_unsafe_sql)?; let params = match map_value(statement, "params") { Some(Value::Array(params)) => sqlite_params(params, limits)?, + Some(Value::Null) | None => Vec::new(), Some(_) => return Err(VmError::TypeMismatch("SQLite parameter array")), - None => Vec::new(), }; let query = match map_value(statement, "query") { Some(Value::Bool(query)) => *query, + Some(Value::Null) | None => false, Some(_) => return Err(VmError::TypeMismatch("SQLite query flag")), - None => false, }; let statement_limits = match map_value(statement, "limits") { Some(Value::Map(statement_limits)) => parse_query_limits(statement_limits, limits)?, + Some(Value::Null) | None => limits, Some(_) => return Err(VmError::TypeMismatch("SQLite limits map")), - None => limits, }; Ok(TransactionStatement { sql, @@ -1491,17 +1685,20 @@ pub(super) fn builtin_sqlite_transaction_impl( connection.transaction_with_behavior(TransactionBehavior::Immediate)?; let mut results = Vec::with_capacity(statements.len()); for statement in statements { - let value = if statement.query { - query_with_connection( + let result = if statement.query { + let value = query_with_connection( &transaction, &statement.sql, &statement.params, statement.limits, - )? + )?; + transaction_result_value("query", None, Some(value)) } else { - execute_with_connection(&transaction, &statement.sql, &statement.params)? + let value = + execute_with_connection(&transaction, &statement.sql, &statement.params)?; + transaction_result_value("execute", Some(value), None) }; - results.push(Value::Map(Arc::new(value))); + results.push(result); } transaction.commit()?; Ok(results) @@ -1523,6 +1720,113 @@ pub(super) fn builtin_sqlite_close_impl(vm: &mut Vm, db_id: i64) -> VmResult<()> Ok(()) } +struct SqliteAdapterContract { + name: &'static str, + arity: u8, + adapter: fn(&mut Vm, &[Value]) -> VmResult, + runtime_owned_pending: bool, +} + +const SQLITE_ADAPTER_CONTRACTS: &[SqliteAdapterContract] = &[ + SqliteAdapterContract { + name: "sqlite::open", + arity: 1, + adapter: open_adapter, + runtime_owned_pending: false, + }, + SqliteAdapterContract { + name: "sqlite::execute", + arity: 3, + adapter: execute_adapter, + runtime_owned_pending: true, + }, + SqliteAdapterContract { + name: "sqlite::query", + arity: 4, + adapter: query_adapter, + runtime_owned_pending: true, + }, + SqliteAdapterContract { + name: "sqlite::transaction", + arity: 2, + adapter: transaction_adapter, + runtime_owned_pending: true, + }, + SqliteAdapterContract { + name: "sqlite::close", + arity: 1, + adapter: close_adapter, + runtime_owned_pending: false, + }, +]; + +fn open_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + builtin_sqlite_open(vm, args).map(IntoHostCallOutcome::into_host_call_outcome) +} + +fn execute_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + builtin_sqlite_execute(vm, args).map(IntoHostCallOutcome::into_host_call_outcome) +} + +fn query_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + builtin_sqlite_query(vm, args).map(IntoHostCallOutcome::into_host_call_outcome) +} + +fn transaction_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + builtin_sqlite_transaction(vm, args).map(IntoHostCallOutcome::into_host_call_outcome) +} + +fn close_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + builtin_sqlite_close(vm, args).map(IntoHostCallOutcome::into_host_call_outcome) +} + +/// Registers SQLite host functions from [`super::standard_host_catalog`]. +pub fn register_sqlite_builtin_module(registry: &mut HostFunctionRegistry) -> VmResult<()> { + let catalog = super::standard_host_catalog(); + register_sqlite_builtin_module_from_catalog(registry, catalog.as_ref()) +} + +/// Registers SQLite host functions using schemas from `catalog`. +/// +/// `catalog` must declare the same SQLite named structs and function overloads +/// as [`super::sqlite_host_catalog`]; registered fingerprints match the supplied +/// catalog so exact compile/bind pairs. +pub fn register_sqlite_builtin_module_from_catalog( + registry: &mut HostFunctionRegistry, + catalog: &HostApiCatalog, +) -> VmResult<()> { + let contract = super::sqlite_host_catalog(); + let catalog_fingerprint = catalog.fingerprint(); + let contract_fingerprint = contract.fingerprint(); + let schemas = SQLITE_ADAPTER_CONTRACTS + .iter() + .map(|entry| { + host_extension::validate_catalog_import_schemas_with_fingerprints( + catalog, + &contract, + entry.name, + catalog_fingerprint, + contract_fingerprint, + ) + .map(|schemas| (entry, schemas)) + }) + .collect::>>()?; + + registry.transactionally(|staged| { + staged.install_named_struct_schemas(catalog_named_struct_schemas(catalog))?; + for (entry, schemas) in &schemas { + for schema in schemas.iter().cloned() { + staged.register_exact_static(entry.name, entry.arity, schema, entry.adapter)?; + } + staged.authorize_registered_builtin_import(entry.name); + if entry.runtime_owned_pending { + staged.mark_exact_runtime_owned_pending(entry.name)?; + } + } + Ok(()) + }) +} + /// Adapter-owned SQLite embedding-control surface. /// /// The concrete SQLite `configure`/`clear`/policy-read control API lives in diff --git a/src/builtins/runtime/standard_composition.rs b/src/builtins/runtime/standard_composition.rs index 050ba9a1..0658abeb 100644 --- a/src/builtins/runtime/standard_composition.rs +++ b/src/builtins/runtime/standard_composition.rs @@ -2,9 +2,9 @@ //! //! This module implements [`StandardSurfaceComposition`] for the same-crate //! standard builtin layer. It is the *only* place that knows which concrete -//! standard domains exist (`io::`, `http::`, `sqlite::`) and which builtin -//! modules implement them. `src/vm` consumes it through the generic trait and -//! never names a domain, namespace prefix, or feature. +//! standard domains exist (`io::`, `http::`, `sqlite::`, `jit::`) and which +//! builtin modules implement them. `src/vm` consumes it through the generic +//! trait and never names a domain, namespace prefix, or feature. //! //! The implementation is *caller-provided per-instance state*: the outer //! standard-runtime constructor installs one instance on the standard @@ -25,9 +25,10 @@ use crate::builtins::default_host_callable; /// The concrete standard-surface composition for this build. /// /// Feature-gated composition happens through the existing standard builtin -/// helpers: IO is always present under `runtime`, HTTP under `http-client`, -/// SQLite under `sqlite`. Required/present/stage is one opaque operation; -/// the VM core never sees a surface mask or count. +/// helpers: IO is always present under `runtime`, native HTTP/SSE under +/// `http-client` on non-wasm targets, SQLite under `sqlite`, and JIT config +/// under `runtime`. Required/present/stage is one opaque operation; the VM +/// core never sees a surface mask or count. #[derive(Debug)] pub(crate) struct StandardSurfaceCompositionImpl; diff --git a/src/bytecode.rs b/src/bytecode.rs index 11b55b7f..f5d6e179 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -3,14 +3,15 @@ use std::fmt; use std::hash::{BuildHasherDefault, Hash, Hasher}; use std::sync::{Arc, OnceLock}; -use crate::compiler::TypeSchema; +use crate::compiler::{StructDecl, TypeSchema}; use crate::host_api::HostImportSchema; /// Bytecode ABI version used for VM-internal cache identity (JIT trace cache, /// program cache keys). The VMBC wire format version lives in `src/vmbc.rs` -/// (`VERSION_V12`); both were bumped together for the static builtin ID break -/// and again for the direct script-call (`CallScript`) opcode break. -pub const BYTECODE_ABI_VERSION: u16 = 12; +/// (`VERSION_V13`); both were bumped together for the static builtin ID break +/// and again for the direct script-call (`CallScript`) opcode break. Version 13 +/// adds an explicit guest named-struct declaration section. +pub const BYTECODE_ABI_VERSION: u16 = 13; pub type SharedString = Arc; pub type SharedBytes = Arc>; @@ -639,6 +640,10 @@ pub struct Program { /// non-public avoids expanding the public layout while exposing the /// semantic metadata through [`Self::host_import_schemas`]. pub(crate) host_import_schemas: Vec>, + /// Compiler struct declarations used to expand guest `TypeSchema::Named` + /// at runtime. Host catalog bodies stay on the bound registry table and + /// win on name lookup; missing names still fail closed. + pub(crate) named_struct_decls: HashMap, pub debug: Option, pub type_map: Option, pub script_functions: Vec, @@ -660,6 +665,7 @@ impl Program { local_count, imports: Vec::new(), host_import_schemas: Vec::new(), + named_struct_decls: HashMap::new(), debug: None, type_map: None, script_functions: Vec::new(), @@ -684,6 +690,7 @@ impl Program { local_count, imports: Vec::new(), host_import_schemas: Vec::new(), + named_struct_decls: HashMap::new(), debug, type_map: None, script_functions: Vec::new(), @@ -709,6 +716,7 @@ impl Program { local_count, imports, host_import_schemas: Vec::new(), + named_struct_decls: HashMap::new(), debug, type_map: None, script_functions: Vec::new(), @@ -783,6 +791,24 @@ impl Program { self } + /// Attaches compiler struct declarations used to expand guest Named + /// schemas at runtime. Host catalog bodies remain on the registry table. + pub(crate) fn with_named_struct_decls( + mut self, + named_struct_decls: HashMap, + ) -> Self { + self.named_struct_decls = named_struct_decls; + self + } + + /// Guest/source named-struct declarations transported on this program. + /// + /// Host catalog structs are not included; they stay on the bound registry + /// table and fail closed when that table is absent. + pub fn named_struct_decls(&self) -> &HashMap { + &self.named_struct_decls + } + pub fn with_local_count(mut self, local_count: usize) -> Self { self.local_count = local_count; self diff --git a/src/cli.rs b/src/cli.rs index bd6d5310..d6aa2831 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1640,7 +1640,7 @@ mod tests { #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] let mut modules = vec!["bytes", "io", "re", "json", "jit", "math"]; #[cfg(not(all(feature = "sqlite", not(target_arch = "wasm32"))))] - let modules = vec!["bytes", "io", "re", "json", "jit", "math"]; + let modules = ["bytes", "io", "re", "json", "jit", "math"]; #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] modules.push("sqlite"); assert_eq!( diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index 9e959c60..62e76c7f 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -259,6 +259,13 @@ impl Compiler { program.exported_callables = exported_callables; program.imports = self.host_imports; program = program.with_optional_host_import_schemas(self.host_import_schemas); + let guest_struct_decls = self + .struct_schemas + .iter() + .filter(|(_, decl)| decl.is_guest()) + .map(|(name, decl)| (name.clone(), decl.clone())) + .collect(); + program = program.with_named_struct_decls(guest_struct_decls); Ok(program) } @@ -1833,13 +1840,23 @@ impl Compiler { .params .iter() .zip(&resolution.passing) - .map(|(param, passing)| HostImportParam { + .enumerate() + .map(|(index, (param, passing))| HostImportParam { name: param.name.clone(), - schema: super::host_conversion::to_host_schema(¶m.schema), + schema: resolution + .host_params + .get(index) + .cloned() + .unwrap_or_else(|| super::host_conversion::to_host_schema(¶m.schema)), passing: *passing, }) .collect(), - return_type: super::host_conversion::to_host_schema(&resolution.return_type), + return_type: if resolution.host_return_type != crate::host_api::HostTypeSchema::Unknown + { + resolution.host_return_type.clone() + } else { + super::host_conversion::to_host_schema(&resolution.return_type) + }, fingerprint: resolution.fingerprint, }; let base_index = self diff --git a/src/compiler/frontends/mod.rs b/src/compiler/frontends/mod.rs index a853e36a..cb3bd0e2 100644 --- a/src/compiler/frontends/mod.rs +++ b/src/compiler/frontends/mod.rs @@ -44,9 +44,9 @@ pub(super) fn parse_source( parse_source_with_source_id(source, flavor, options, 0) } -/// Parse one source unit for bytecode compilation. The runtime standard -/// catalog is reserved for semantic analysis; explicit caller catalogs remain -/// available for custom host APIs. +/// Parse one source unit for bytecode compilation. Callers that need named +/// host structs must attach a catalog through compile options; the compile +/// entry points install the HTTP catalog when the runtime HTTP surface is on. pub(super) fn parse_source_for_compile( source: &str, flavor: SourceFlavor, @@ -240,15 +240,19 @@ fn parse_with_parser( dialect, catalog, )?, - None => Parser::new( - source, - source_id, - allow_implicit_externs, - allow_implicit_semicolons, - enforce_mutable_bindings, - import_scan_mode, - dialect, - )?, + None => { + let mut parser = Parser::new( + source, + source_id, + allow_implicit_externs, + allow_implicit_semicolons, + enforce_mutable_bindings, + import_scan_mode, + dialect, + )?; + parser.install_standard_runtime_named_structs(); + parser + } }; let stmts = parser.parse_program()?; Ok(FrontendIr { diff --git a/src/compiler/host_call_resolve.rs b/src/compiler/host_call_resolve.rs index 2d63ab53..d8880c62 100644 --- a/src/compiler/host_call_resolve.rs +++ b/src/compiler/host_call_resolve.rs @@ -435,8 +435,9 @@ fn resolve_candidate_refs<'a, A: ActualCallArgView>( let mut score = MatchScore::default(); let mut passing_conforms = true; for (param, arg) in function.params.iter().zip(args.iter()) { - let expected_schema = param.ty.to_compiler_schema(); - score = score.combined(score_pair(&expected_schema, arg.schema())); + let expected_schema = param.ty.to_compiler_object_schema(); + let actual_schema = expand_actual_named(arg.schema(), ¶m.ty); + score = score.combined(score_pair(&expected_schema, &actual_schema)); if passing_conforms && !arg.passing_matches_param(param.passing) { passing_conforms = false; } @@ -501,6 +502,12 @@ fn build_resolved( return_type: function.return_type.to_compiler_schema(), passing: function.params.iter().map(|param| param.passing).collect(), fingerprint, + host_params: function + .params + .iter() + .map(|param| param.ty.clone()) + .collect(), + host_return_type: function.return_type.clone(), } } @@ -605,10 +612,27 @@ fn score_pair(expected: &TypeSchema, actual: &TypeSchema) -> MatchScore { } match (expected, actual) { - (Optional(e), Optional(a)) | (Array(e), Array(a)) | (Map(e), Map(a)) => { - MatchScore::default() - .plus_exact() - .combined(score_pair(e, a)) + (Optional(e), Optional(a)) => MatchScore::default() + .plus_exact() + .combined(score_pair(e, a)), + (Optional(_), Null) => MatchScore::default().plus_exact(), + (Optional(e), a) => score_pair(e, a), + (Array(e), Array(a)) | (Map(e), Map(a)) => MatchScore::default() + .plus_exact() + .combined(score_pair(e, a)), + (Array(e), ArrayTuple(items)) => { + let mut total = MatchScore::default().plus_exact(); + for item in items { + total = total.combined(score_pair(e, item)); + } + total + } + (Map(e), Object(a_fields)) => { + let mut total = MatchScore::default().plus_exact(); + for a_schema in a_fields.values() { + total = total.combined(score_pair(e, a_schema)); + } + total } (ArrayTuple(e_items), ArrayTuple(a_items)) => { if e_items.len() != a_items.len() { @@ -673,14 +697,18 @@ fn score_pair(expected: &TypeSchema, actual: &TypeSchema) -> MatchScore { } } (Object(e_fields), Object(a_fields)) => { - if e_fields.len() != a_fields.len() - || e_fields.keys().any(|name| !a_fields.contains_key(name)) - { + if a_fields.keys().any(|name| !e_fields.contains_key(name)) { MatchScore::default().plus_mismatch() } else { let mut total = MatchScore::default().plus_exact(); for (name, e_schema) in e_fields.iter() { - total = total.combined(score_pair(e_schema, &a_fields[name])); + match a_fields.get(name) { + Some(a_schema) => { + total = total.combined(score_pair(e_schema, a_schema)); + } + None if matches!(e_schema, Optional(_)) => {} + None => return MatchScore::default().plus_mismatch(), + } } total } @@ -764,8 +792,9 @@ fn best_concrete_mismatch<'f, A: ActualCallArgView>( .zip(args.iter()) .enumerate() .find_map(|(index, (param, arg))| { - let expected_schema = param.ty.to_compiler_schema(); - if score_pair(&expected_schema, arg.schema()).mismatches > 0 { + let expected_schema = param.ty.to_compiler_object_schema(); + let actual_schema = expand_actual_named(arg.schema(), ¶m.ty); + if score_pair(&expected_schema, &actual_schema).mismatches > 0 { Some(ConcreteMismatch { index, expected: schema_label(¶m.ty), @@ -941,6 +970,62 @@ fn schema_label(schema: &crate::host_api::HostTypeSchema) -> String { format!("fn({params}) -> {}", schema_label(result)) } HostTypeSchema::Resource(key) => format!("resource<{key}>"), + HostTypeSchema::Named { name, .. } => name.clone(), + } +} + +fn expand_actual_named( + actual: &TypeSchema, + expected: &crate::host_api::HostTypeSchema, +) -> TypeSchema { + use crate::host_api::HostTypeSchema; + match (actual, expected) { + (TypeSchema::Named(actual_name, _), HostTypeSchema::Named { name, .. }) + if actual_name == name => + { + expected.to_compiler_object_schema() + } + (TypeSchema::Optional(inner), HostTypeSchema::Optional(expected_inner)) => { + TypeSchema::Optional(Box::new(expand_actual_named(inner, expected_inner))) + } + (TypeSchema::Array(inner), HostTypeSchema::Array(expected_inner)) => { + TypeSchema::Array(Box::new(expand_actual_named(inner, expected_inner))) + } + (TypeSchema::Map(inner), HostTypeSchema::Map(expected_inner)) => { + TypeSchema::Map(Box::new(expand_actual_named(inner, expected_inner))) + } + (TypeSchema::Object(actual_fields), HostTypeSchema::Named { fields, .. }) => { + let mut expanded = std::collections::HashMap::new(); + for (name, actual_ty) in actual_fields { + if let Some(field) = fields.iter().find(|field| field.name == *name) { + expanded.insert(name.clone(), expand_actual_named(actual_ty, &field.ty)); + } else { + expanded.insert(name.clone(), actual_ty.clone()); + } + } + TypeSchema::Object(expanded) + } + ( + TypeSchema::Callable { params, result }, + HostTypeSchema::Callable { + params: expected_params, + result: expected_result, + }, + ) => TypeSchema::Callable { + params: if params.len() != expected_params.len() { + params.clone() + } else { + params + .iter() + .zip(expected_params.iter()) + .map(|(actual_param, expected_param)| { + expand_actual_named(actual_param, expected_param) + }) + .collect() + }, + result: Box::new(expand_actual_named(result, expected_result)), + }, + _ => actual.clone(), } } #[cfg(test)] @@ -2463,4 +2548,344 @@ mod tests { "deferred passing must equal the schema-only result" ); } + + fn point_type() -> HostTypeSchema { + HostTypeSchema::named_struct( + "Point", + vec![ + crate::host_api::HostStructField::new("x", HostTypeSchema::Int), + crate::host_api::HostStructField::new("y", HostTypeSchema::Int), + ], + ) + } + + fn point_catalog() -> HostApiCatalog { + let mut b = HostApiBuilder::new(); + b.named_struct(crate::host_api::HostStructSchema::new( + "Point", + vec![ + crate::host_api::HostStructField::new("x", HostTypeSchema::Int), + crate::host_api::HostStructField::new("y", HostTypeSchema::Int), + ], + )); + b.function(HostFunctionSchema::with_return( + "make_point", + vec![], + point_type(), + )); + b.function(HostFunctionSchema::with_return( + "take_point", + vec![value_param("p", point_type())], + HostTypeSchema::Int, + )); + b.build().expect("point catalog") + } + + #[test] + fn named_struct_return_stays_named() { + let catalog = point_catalog(); + let resolver = HostCallResolver::new(&catalog); + let resolved = resolver.resolve("make_point", &[]).expect("resolve"); + assert_eq!(resolved.return_type, Ts::Named("Point".to_string(), vec![])); + } + + #[test] + fn object_literal_matches_named_struct_param() { + let catalog = point_catalog(); + let resolver = HostCallResolver::new(&catalog); + let mut fields = std::collections::HashMap::new(); + fields.insert("x".to_string(), Ts::Int); + fields.insert("y".to_string(), Ts::Int); + let resolved = resolver + .resolve("take_point", &[Ts::Object(fields)]) + .expect("object literal should match named struct"); + assert_eq!( + resolved.params[0].schema, + Ts::Named("Point".to_string(), vec![]) + ); + } + + #[test] + fn named_value_matches_named_struct_param() { + let catalog = point_catalog(); + let resolver = HostCallResolver::new(&catalog); + let resolved = resolver + .resolve("take_point", &[Ts::Named("Point".to_string(), vec![])]) + .expect("named Point should match named Point"); + assert_eq!(resolved.name, "take_point"); + } + + #[test] + fn dynamic_map_does_not_match_named_struct_param() { + let catalog = point_catalog(); + let resolver = HostCallResolver::new(&catalog); + let err = resolver + .resolve("take_point", &[Ts::Map(Box::new(Ts::Int))]) + .unwrap_err(); + match err { + HostCallResolveError::NoMatch { detail, .. } => { + assert!( + detail.contains("Point") || detail.contains("object"), + "mismatch should mention named struct or object, got {detail}" + ); + } + other => panic!("expected NoMatch, got {other:?}"), + } + } + + #[test] + fn object_literal_missing_field_does_not_match_named_struct() { + let catalog = point_catalog(); + let resolver = HostCallResolver::new(&catalog); + let mut fields = std::collections::HashMap::new(); + fields.insert("x".to_string(), Ts::Int); + let err = resolver + .resolve("take_point", &[Ts::Object(fields)]) + .unwrap_err(); + assert!(matches!(err, HostCallResolveError::NoMatch { .. })); + } + + fn inner_type() -> HostTypeSchema { + HostTypeSchema::named_struct( + "Inner", + vec![crate::host_api::HostStructField::new( + "x", + HostTypeSchema::Int, + )], + ) + } + + fn nested_named_catalog() -> HostApiCatalog { + let inner_fields = vec![crate::host_api::HostStructField::new( + "x", + HostTypeSchema::Int, + )]; + let mut b = HostApiBuilder::new(); + b.named_struct(crate::host_api::HostStructSchema::new( + "Inner", + inner_fields, + )); + b.named_struct(crate::host_api::HostStructSchema::new( + "Outer", + vec![crate::host_api::HostStructField::new("inner", inner_type())], + )); + b.function(HostFunctionSchema::with_return( + "take_outer", + vec![value_param( + "o", + HostTypeSchema::named_struct( + "Outer", + vec![crate::host_api::HostStructField::new("inner", inner_type())], + ), + )], + HostTypeSchema::Int, + )); + b.function(HostFunctionSchema::with_return( + "take_cb", + vec![value_param( + "cb", + HostTypeSchema::Callable { + params: vec![inner_type()], + result: Box::new(HostTypeSchema::Int), + }, + )], + HostTypeSchema::Int, + )); + b.build().expect("nested named catalog") + } + + #[test] + fn nested_named_field_in_object_literal_matches() { + let catalog = nested_named_catalog(); + let resolver = HostCallResolver::new(&catalog); + let mut outer = std::collections::HashMap::new(); + outer.insert("inner".to_string(), Ts::Named("Inner".to_string(), vec![])); + resolver + .resolve("take_outer", &[Ts::Object(outer)]) + .expect("object literal with a nested named field should match"); + } + + #[test] + fn nested_named_in_callable_param_matches() { + let catalog = nested_named_catalog(); + let resolver = HostCallResolver::new(&catalog); + let actual = Ts::Callable { + params: vec![Ts::Named("Inner".to_string(), vec![])], + result: Box::new(Ts::Int), + }; + resolver + .resolve("take_cb", &[actual]) + .expect("callable whose param is a nested named struct should match"); + } + + #[test] + fn callable_unequal_arity_is_rejected() { + let mut builder = HostApiBuilder::new(); + builder.function(HostFunctionSchema::with_return( + "apply", + vec![value_param( + "cb", + HostTypeSchema::Callable { + params: vec![HostTypeSchema::Int], + result: Box::new(HostTypeSchema::Int), + }, + )], + HostTypeSchema::Int, + )); + let catalog = builder.build().expect("valid callable catalog"); + let resolver = HostCallResolver::new(&catalog); + + let extra_actual = Ts::Callable { + params: vec![Ts::Int, Ts::String], + result: Box::new(Ts::Int), + }; + assert!( + matches!( + resolver.resolve("apply", &[extra_actual]), + Err(HostCallResolveError::NoMatch { .. }) + ), + "extra callable parameter must not be dropped by zip expansion" + ); + + let fewer_actual = Ts::Callable { + params: Vec::new(), + result: Box::new(Ts::Int), + }; + assert!( + matches!( + resolver.resolve("apply", &[fewer_actual]), + Err(HostCallResolveError::NoMatch { .. }) + ), + "fewer callable parameters must not match" + ); + } + + fn maybe_point_fields() -> Vec { + vec![ + crate::host_api::HostStructField::new( + "x", + HostTypeSchema::Optional(Box::new(HostTypeSchema::Int)), + ), + crate::host_api::HostStructField::new("y", HostTypeSchema::Int), + ] + } + + fn optional_field_catalog() -> HostApiCatalog { + let mut b = HostApiBuilder::new(); + b.named_struct(crate::host_api::HostStructSchema::new( + "MaybePoint", + maybe_point_fields(), + )); + b.function(HostFunctionSchema::with_return( + "take_maybe", + vec![value_param( + "p", + HostTypeSchema::named_struct("MaybePoint", maybe_point_fields()), + )], + HostTypeSchema::Int, + )); + b.build().expect("optional field catalog") + } + + fn object_fields(entries: &[(&str, Ts)]) -> Ts { + Ts::Object( + entries + .iter() + .map(|(name, ty)| ((*name).to_string(), ty.clone())) + .collect(), + ) + } + + #[test] + fn object_literal_omitted_or_null_optional_field_matches_named_struct() { + let catalog = optional_field_catalog(); + let resolver = HostCallResolver::new(&catalog); + resolver + .resolve("take_maybe", &[object_fields(&[("y", Ts::Int)])]) + .expect("omitted optional field should match"); + resolver + .resolve( + "take_maybe", + &[object_fields(&[("x", Ts::Null), ("y", Ts::Int)])], + ) + .expect("explicit null optional field should match"); + resolver + .resolve( + "take_maybe", + &[object_fields(&[("x", Ts::Int), ("y", Ts::Int)])], + ) + .expect("present optional inner type should match"); + } + + #[test] + fn object_literal_extra_or_wrong_optional_field_does_not_match() { + let catalog = optional_field_catalog(); + let resolver = HostCallResolver::new(&catalog); + let extra = resolver + .resolve( + "take_maybe", + &[object_fields(&[("y", Ts::Int), ("z", Ts::Int)])], + ) + .unwrap_err(); + match extra { + HostCallResolveError::NoMatch { detail, .. } => { + assert!( + detail.contains("MaybePoint") && detail.contains("z"), + "extra field diagnostic should name the struct and key, got {detail}" + ); + } + other => panic!("expected NoMatch, got {other:?}"), + } + + let wrong = resolver + .resolve( + "take_maybe", + &[object_fields(&[("x", Ts::String), ("y", Ts::Int)])], + ) + .unwrap_err(); + assert!(matches!(wrong, HostCallResolveError::NoMatch { .. })); + } + + fn take_ints_catalog() -> HostApiCatalog { + let mut b = HostApiBuilder::new(); + b.function(HostFunctionSchema::with_return( + "take_ints", + vec![value_param( + "xs", + HostTypeSchema::Array(Box::new(HostTypeSchema::Int)), + )], + HostTypeSchema::Int, + )); + b.build().expect("array catalog") + } + + #[test] + fn array_param_matches_array_tuple_of_same_element() { + let catalog = take_ints_catalog(); + let resolver = HostCallResolver::new(&catalog); + resolver + .resolve("take_ints", &[Ts::Array(Box::new(Ts::Int))]) + .expect("array should match array"); + resolver + .resolve("take_ints", &[Ts::ArrayTuple(vec![Ts::Int, Ts::Int])]) + .expect("array tuple of ints should match array"); + } + + #[test] + fn array_param_rejects_array_tuple_with_wrong_element() { + let catalog = take_ints_catalog(); + let resolver = HostCallResolver::new(&catalog); + let err = resolver + .resolve("take_ints", &[Ts::ArrayTuple(vec![Ts::Int, Ts::String])]) + .unwrap_err(); + match err { + HostCallResolveError::NoMatch { detail, .. } => { + assert!( + detail.contains("array"), + "array/tuple mismatch should name array, got {detail}" + ); + } + other => panic!("expected NoMatch, got {other:?}"), + } + } } diff --git a/src/compiler/host_conversion.rs b/src/compiler/host_conversion.rs index 435c95d6..c6d82f47 100644 --- a/src/compiler/host_conversion.rs +++ b/src/compiler/host_conversion.rs @@ -18,14 +18,17 @@ //! * Every [`HostTypeSchema::Resource`] becomes the distinct nominal //! [`TypeSchema::Resource`] (via [`crate::host_api::ResourceTypeKey`]) //! carrying the same shared key. -//! * No host schema is ever collapsed onto the structural -//! [`TypeSchema::Named`] / [`TypeSchema::Map`] fallback. +//! * Every [`HostTypeSchema::Named`] becomes [`TypeSchema::Named`] (name +//! identity, empty type args). Field shapes are available as +//! [`TypeSchema::Object`] via [`HostTypeSchema::to_compiler_object_schema`]. +//! * Dynamic [`HostTypeSchema::Map`] stays [`TypeSchema::Map`]. Resources are +//! never collapsed onto Named/Map. //! * Compiler-irrelevant host details (parameter passing modes etc.) are not //! carried across; only the value shape is translated. -use crate::host_api::HostTypeSchema; +use crate::host_api::{HostStructField, HostStructSchema, HostTypeSchema}; -use super::TypeSchema; +use super::{StructDecl, StructDeclOrigin, TypeSchema}; impl HostTypeSchema { /// Maps this host schema onto the compiler's [`TypeSchema`], recursively @@ -35,8 +38,8 @@ impl HostTypeSchema { /// integration calls when it needs the compiler's semantic view of a /// host signature. Every [`HostTypeSchema::Resource`] becomes the /// distinct nominal [`TypeSchema::Resource`] carrying the same shared - /// [`ResourceTypeKey`]; no host schema is ever collapsed to a - /// structural `Named`/`Map` fallback. + /// [`ResourceTypeKey`]. Named structs become [`TypeSchema::Named`]; + /// dynamic maps stay maps. pub fn to_compiler_schema(&self) -> TypeSchema { match self { HostTypeSchema::Unknown => TypeSchema::Unknown, @@ -57,8 +60,67 @@ impl HostTypeSchema { result: Box::new(result.to_compiler_schema()), }, HostTypeSchema::Resource(key) => TypeSchema::Resource(key.clone()), + HostTypeSchema::Named { name, .. } => TypeSchema::Named(name.clone(), Vec::new()), } } + + /// Structural object schema used for field access and object-literal + /// matching. Named structs expand to [`TypeSchema::Object`]; other + /// variants match [`Self::to_compiler_schema`]. + pub fn to_compiler_object_schema(&self) -> TypeSchema { + match self { + HostTypeSchema::Named { fields, .. } => match_object_schema_from_fields(fields), + HostTypeSchema::Array(inner) => { + TypeSchema::Array(Box::new(inner.to_compiler_object_schema())) + } + HostTypeSchema::Map(inner) => { + TypeSchema::Map(Box::new(inner.to_compiler_object_schema())) + } + HostTypeSchema::Optional(inner) => { + TypeSchema::Optional(Box::new(inner.to_compiler_object_schema())) + } + HostTypeSchema::Callable { params, result } => TypeSchema::Callable { + params: params.iter().map(Self::to_compiler_object_schema).collect(), + result: Box::new(result.to_compiler_object_schema()), + }, + other => other.to_compiler_schema(), + } + } +} + +impl HostStructSchema { + /// Object field schema for this catalog struct. + pub fn to_compiler_object_schema(&self) -> TypeSchema { + object_schema_from_fields(&self.fields) + } + + /// Parser/type-checker struct declaration installed from the catalog. + pub fn to_struct_decl(&self) -> StructDecl { + StructDecl { + name: self.name.clone(), + type_params: Vec::new(), + body_schema: self.to_compiler_object_schema(), + origin: StructDeclOrigin::Catalog, + } + } +} + +fn object_schema_from_fields(fields: &[HostStructField]) -> TypeSchema { + TypeSchema::Object( + fields + .iter() + .map(|field| (field.name.clone(), field.ty.to_compiler_schema())) + .collect(), + ) +} + +fn match_object_schema_from_fields(fields: &[HostStructField]) -> TypeSchema { + TypeSchema::Object( + fields + .iter() + .map(|field| (field.name.clone(), field.ty.to_compiler_object_schema())) + .collect(), + ) } /// Converts a compiler schema back to the host-facing schema retained in the @@ -177,4 +239,49 @@ mod tests { TypeSchema::Bytes ); } + + #[test] + fn to_compiler_schema_maps_named_struct_to_named_not_map() { + let host = HostTypeSchema::named_struct( + "Point", + vec![ + crate::host_api::HostStructField::new("x", HostTypeSchema::Int), + crate::host_api::HostStructField::new("y", HostTypeSchema::Int), + ], + ); + let mapped = host.to_compiler_schema(); + assert_eq!(mapped, TypeSchema::Named("Point".to_string(), vec![])); + assert_ne!(mapped, TypeSchema::Map(Box::new(TypeSchema::Unknown))); + let object = host.to_compiler_object_schema(); + match object { + TypeSchema::Object(fields) => { + assert_eq!(fields.get("x"), Some(&TypeSchema::Int)); + assert_eq!(fields.get("y"), Some(&TypeSchema::Int)); + } + other => panic!("expected object schema, got {other:?}"), + } + } + + #[test] + fn to_compiler_schema_preserves_nested_resource_in_named_struct() { + let host = HostTypeSchema::named_struct( + "HandleBox", + vec![crate::host_api::HostStructField::new( + "file", + HostTypeSchema::Resource(io_file_key()), + )], + ); + let mapped = host.to_compiler_schema(); + assert_eq!(mapped, TypeSchema::Named("HandleBox".to_string(), vec![])); + let object = host.to_compiler_object_schema(); + match object { + TypeSchema::Object(fields) => { + assert_eq!( + fields.get("file"), + Some(&TypeSchema::Resource(io_file_key())) + ); + } + other => panic!("expected object schema, got {other:?}"), + } + } } diff --git a/src/compiler/ir.rs b/src/compiler/ir.rs index 312a2652..5cc26766 100644 --- a/src/compiler/ir.rs +++ b/src/compiler/ir.rs @@ -3,7 +3,9 @@ use std::hash::{Hash, Hasher}; use crate::ValueType; use crate::builtins::default_host_callable; -use crate::host_api::{HostApiFingerprint, HostFunctionSchema, HostParamPassing, ResourceTypeKey}; +use crate::host_api::{ + HostApiFingerprint, HostFunctionSchema, HostParamPassing, HostTypeSchema, ResourceTypeKey, +}; use super::ParseError; use super::modules::SymbolId; @@ -419,13 +421,37 @@ pub struct ResolvedHostCall { pub passing: Vec, /// The catalog fingerprint at resolution time, for provenance/ABI ties. pub fingerprint: HostApiFingerprint, + /// Host-facing parameter schemas, index-aligned with [`Self::params`]. + /// + /// These preserve [`HostTypeSchema::Named`] identity for the VMBC sidecar + /// instead of collapsing named structs onto `map`. + pub host_params: Vec, + /// Host-facing return schema, including named-struct identity. + pub host_return_type: HostTypeSchema, } -#[derive(Clone, Debug, PartialEq, Eq)] +/// Provenance for a parser/compiler struct declaration. +/// +/// Catalog-installed host structs stay on the registry side at runtime. +/// Only source/guest declarations are transported on `Program.named_struct_decls`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum StructDeclOrigin { + Guest, + Catalog, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct StructDecl { pub name: String, pub type_params: Vec, pub body_schema: TypeSchema, + pub origin: StructDeclOrigin, +} + +impl StructDecl { + pub(crate) fn is_guest(&self) -> bool { + matches!(self.origin, StructDeclOrigin::Guest) + } } fn known_host_accepts_arity(name: &str, arity: u8) -> bool { @@ -1875,7 +1901,7 @@ mod host_api_ir_metadata_tests { mod call_resolution_carrier_tests { use super::{Expr, ResolvedHostCall, TypeSchema}; use crate::compiler::ResolvedHostParam; - use crate::host_api::{HostApiFingerprint, HostParamPassing}; + use crate::host_api::{HostApiFingerprint, HostParamPassing, HostTypeSchema}; fn fingerprint(n: u64) -> HostApiFingerprint { serde_json::from_value(serde_json::Value::Number(n.into())).unwrap() @@ -1891,6 +1917,8 @@ mod call_resolution_carrier_tests { return_type: TypeSchema::Int, passing: vec![HostParamPassing::Borrow], fingerprint: fingerprint(7), + host_params: vec![HostTypeSchema::Int], + host_return_type: HostTypeSchema::Int, } } @@ -1990,7 +2018,7 @@ mod call_resolution_carrier_tests { #[cfg(test)] mod type_schema_contains_resource_tests { - use super::{StructDecl, TypeSchema}; + use super::{StructDecl, StructDeclOrigin, TypeSchema}; use crate::host_api::ResourceTypeKey; use std::collections::HashMap; @@ -2136,6 +2164,7 @@ mod type_schema_contains_resource_tests { "handle".to_string(), resource(), )])), + origin: StructDeclOrigin::Guest, }, )]); let wrapper = TypeSchema::Named("wrapper".to_string(), Vec::new()); @@ -2153,6 +2182,7 @@ mod type_schema_contains_resource_tests { "value".to_string(), TypeSchema::GenericParam("T".to_string()), )])), + origin: StructDeclOrigin::Guest, }, )]); let resource_wrapper = TypeSchema::Named("wrapper".to_string(), vec![resource()]); @@ -2177,6 +2207,7 @@ mod type_schema_contains_resource_tests { )))], ), )])), + origin: StructDeclOrigin::Guest, }, )]); let node = TypeSchema::Named("node".to_string(), vec![TypeSchema::Int]); diff --git a/src/compiler/lifetime/availability.rs b/src/compiler/lifetime/availability.rs index ec815a77..e555d1e9 100644 --- a/src/compiler/lifetime/availability.rs +++ b/src/compiler/lifetime/availability.rs @@ -2302,6 +2302,8 @@ mod tests { return_type: TypeSchema::Null, passing: vec![HostParamPassing::TakeOwned], fingerprint: HostApiFingerprint::from_wire(1), + host_params: Vec::new(), + host_return_type: crate::host_api::HostTypeSchema::Unknown, }) } @@ -2337,6 +2339,8 @@ mod tests { return_type: TypeSchema::Null, passing: vec![mode], fingerprint: HostApiFingerprint::from_wire(2), + host_params: Vec::new(), + host_return_type: crate::host_api::HostTypeSchema::Unknown, })), None, ) diff --git a/src/compiler/lifetime/liveness.rs b/src/compiler/lifetime/liveness.rs index ac1d37fc..69f6f26b 100644 --- a/src/compiler/lifetime/liveness.rs +++ b/src/compiler/lifetime/liveness.rs @@ -2013,6 +2013,8 @@ mod call_resolution_carrier_tests { return_type: TypeSchema::Int, passing: vec![HostParamPassing::Borrow], fingerprint: fingerprint(3), + host_params: Vec::new(), + host_return_type: crate::host_api::HostTypeSchema::Unknown, } } diff --git a/src/compiler/linker.rs b/src/compiler/linker.rs index 22e463f7..a3b7bbe2 100644 --- a/src/compiler/linker.rs +++ b/src/compiler/linker.rs @@ -1658,7 +1658,7 @@ mod linker_metadata_remap_tests { } fn host_candidate(name: &str, params: Vec) -> HostFunctionSchema { - HostFunctionSchema::with_return(name, params, HostTypeSchema::Unknown) + HostFunctionSchema::with_return(name, params, crate::host_api::HostTypeSchema::Unknown) } fn symbol(module: u32, index: u32) -> SymbolId { @@ -2107,6 +2107,8 @@ mod linker_metadata_remap_tests { return_type: TypeSchema::Int, passing: vec![crate::host_api::HostParamPassing::Borrow], fingerprint: fingerprint(4), + host_params: Vec::new(), + host_return_type: crate::host_api::HostTypeSchema::Unknown, }; let mut annotated = Expr::Call(7, Vec::new(), Vec::new(), Some(Box::new(res.clone())), None); diff --git a/src/compiler/materialization.rs b/src/compiler/materialization.rs index 2477397d..ecb05b2d 100644 --- a/src/compiler/materialization.rs +++ b/src/compiler/materialization.rs @@ -1328,6 +1328,8 @@ mod tests { return_type: IrTypeSchema::Int, passing: vec![HostParamPassing::Borrow], fingerprint: fingerprint(1), + host_params: Vec::new(), + host_return_type: crate::host_api::HostTypeSchema::Unknown, }; let annotated = Expr::Call(0, Vec::new(), Vec::new(), Some(Box::new(resolution)), None); let ir = ir_with( diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 72799c25..d8c5dc15 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -39,7 +39,7 @@ pub use self::host_call_resolve::{HostCallResolveError, HostCallResolver}; pub use self::ir::{ AssignmentKind, ClosureExpr, Expr, FrontendIr, FunctionDecl, FunctionImpl, FunctionParam, LocalIrBuilder, LocalSlot, MatchPattern, MatchTypePattern, ResolvedHostCall, ResolvedHostParam, - SemanticIndex, Stmt, StructDecl, TypeSchema, + SemanticIndex, Stmt, StructDecl, StructDeclOrigin, TypeSchema, }; pub use self::modules::{ DeclSymbol, ExportEntry, ImportTargetKind, ImportedBinding, ModuleGraph, ModuleId, ModuleNode, diff --git a/src/compiler/parser/mod.rs b/src/compiler/parser/mod.rs index f30b7556..ea792b26 100644 --- a/src/compiler/parser/mod.rs +++ b/src/compiler/parser/mod.rs @@ -289,6 +289,7 @@ impl Parser { )?; parser.host_api_metadata = Some(HostApiIrMetadata::new(catalog.fingerprint())); parser.host_catalog = Some(catalog); + parser.install_host_catalog_structs(); Ok(parser) } @@ -341,6 +342,9 @@ impl Parser { if let Some(catalog) = host_catalog { parser.host_api_metadata = Some(HostApiIrMetadata::new(catalog.fingerprint())); parser.host_catalog = Some(catalog); + parser.install_host_catalog_structs(); + } else { + parser.install_standard_runtime_named_structs(); } for binding in predeclared_locals { parser.predeclare_local(binding)?; @@ -348,6 +352,38 @@ impl Parser { Ok(parser) } + fn install_host_catalog_structs(&mut self) { + let Some(catalog) = self.host_catalog.clone() else { + return; + }; + self.install_named_structs_from_catalog(&catalog); + } + + /// Installs HTTP named structs on catalog-free parse paths without + /// attaching catalog function metadata. + /// + /// Public [`ParserDialect`] / import-scan / REPL-without-HTTP parses have + /// no catalog snapshot. When the HTTP surface is available, install only + /// the authoritative HTTP catalog structs so names such as + /// `SseCallbackAction` resolve. Unrelated standard names (`JitConfig`, + /// `SqliteLimits`) stay unreserved. With HTTP disabled, install nothing. + pub(super) fn install_standard_runtime_named_structs(&mut self) { + debug_assert!(self.host_catalog.is_none()); + #[cfg(all(feature = "http-client", not(target_family = "wasm")))] + { + self.install_named_structs_from_catalog( + crate::builtins::runtime::http::http_host_catalog().as_ref(), + ); + } + } + + fn install_named_structs_from_catalog(&mut self, catalog: &HostApiCatalog) { + for schema in catalog.structs() { + self.struct_schemas + .insert(schema.name.clone(), schema.to_struct_decl()); + } + } + pub(super) fn use_declarations(&self) -> Vec { self.use_declarations.clone() } diff --git a/src/compiler/parser/statements.rs b/src/compiler/parser/statements.rs index 0c629327..8ef78c08 100644 --- a/src/compiler/parser/statements.rs +++ b/src/compiler/parser/statements.rs @@ -549,6 +549,7 @@ impl Parser { name: name.clone(), type_params, body_schema: TypeSchema::Object(fields), + origin: crate::compiler::StructDeclOrigin::Guest, }, ) .is_some() diff --git a/src/compiler/pipeline.rs b/src/compiler/pipeline.rs index abb30714..2dbb21a1 100644 --- a/src/compiler/pipeline.rs +++ b/src/compiler/pipeline.rs @@ -679,7 +679,12 @@ fn schema_is_fully_known(schema: &TypeSchema) -> bool { | TypeSchema::GenericParam(_) => true, // A resource is fully known: its key fixes the nominal type statically. TypeSchema::Resource(_) => true, - TypeSchema::Optional(inner) => schema_is_fully_known(inner), + TypeSchema::Optional(inner) => { + // Optional unknown is the host-catalog stand-in for an unavailable + // union (HTTP request body is string or bytes). Map/array already + // treat unknown payloads as fully known. + matches!(inner.as_ref(), TypeSchema::Unknown) || schema_is_fully_known(inner) + } TypeSchema::Named(_, type_args) => type_args.iter().all(schema_is_fully_known), TypeSchema::Array(item) | TypeSchema::Map(item) => { matches!(item.as_ref(), TypeSchema::Unknown) || schema_is_fully_known(item) @@ -1536,6 +1541,9 @@ fn compile_source_for_repl_with_locals_impl( let source_id = source_map.add_source("", source.to_string()); // REPL parsing/compiler entry state is separate from normal program compilation so // persisted locals do not leak into the generic frontend or IR surface. + #[cfg(all(feature = "http-client", not(target_family = "wasm")))] + let repl_catalog = Some(crate::builtins::runtime::http::http_host_catalog()); + #[cfg(not(all(feature = "http-client", not(target_family = "wasm"))))] let repl_catalog = None; let parsed = frontends::parse_rustscript_repl_source_with_catalog( source, @@ -1645,7 +1653,7 @@ fn compile_source_with_flavor_impl( ) -> Result { let mut source_map = SourceMap::new(); let source_id = source_map.add_source("", source.to_string()); - let effective = CompileSourceFileOptions::default(); + let effective = default_http_compile_catalog_options(&CompileSourceFileOptions::default()); let parsed = frontends::parse_source_for_compile(source, flavor, &effective).map_err(|err| { SourceError::Parse(err.with_line_span_from_source(&source_map, source_id)) @@ -1708,10 +1716,10 @@ fn compile_source_with_flavor_and_options_impl( flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result { - // Explicit catalogs are forwarded unchanged. The runtime standard catalog - // is applied by the analysis entry points; default bytecode compilation - // keeps legacy built-in dispatch for the standard surface. - let effective = options.clone(); + // Explicit catalogs are forwarded unchanged. Otherwise the HTTP catalog is + // installed so `http::client::sse` named structs and function schemas are + // available without converting `io::` / `sqlite::` / `jit::` builtins. + let effective = default_http_compile_catalog_options(options); compile_source_with_flavor_and_options_pipeline(source, flavor, &effective) } @@ -1736,8 +1744,7 @@ fn compile_source_with_flavor_and_options_pipeline( /// Attach the authoritative standard catalog for analysis when the runtime /// surface is enabled and the caller did not supply a custom catalog. Explicit -/// catalogs remain unchanged; default bytecode compilation keeps the catalog- -/// free built-in dispatch path. +/// catalogs remain unchanged. fn default_standard_catalog_options( options: &CompileSourceFileOptions, ) -> CompileSourceFileOptions { @@ -1755,6 +1762,27 @@ fn default_standard_catalog_options( } } +/// Attach the HTTP catalog on bytecode compile when the HTTP surface is on and +/// the caller did not supply a custom catalog. This installs SSE named structs +/// and function schemas without pulling `io::` / `sqlite::` / `jit::` into +/// catalog host imports. +fn default_http_compile_catalog_options( + options: &CompileSourceFileOptions, +) -> CompileSourceFileOptions { + #[cfg(all(feature = "http-client", not(target_family = "wasm")))] + { + let mut effective = options.clone(); + if effective.host_api_catalog().is_none() { + effective.set_host_api_catalog(crate::builtins::runtime::http::http_host_catalog()); + } + effective + } + #[cfg(not(all(feature = "http-client", not(target_family = "wasm"))))] + { + options.clone() + } +} + /// The default catalog for semantic analysis when no custom catalog is /// supplied. Legacy builtin names remain handled by the builtin catalog; /// explicit host catalogs provide resource-aware resolution. @@ -1772,7 +1800,7 @@ fn compile_source_at_path_with_flavor_and_options_impl( flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result { - let effective = options.clone(); + let effective = default_http_compile_catalog_options(options); let loaded = load_units_for_source_file(path, flavor, source, &effective)?; compile_loaded_units( source.to_string(), @@ -1808,7 +1836,7 @@ fn compile_source_file_impl( path: &Path, options: &CompileSourceFileOptions, ) -> Result { - let effective = options.clone(); + let effective = default_http_compile_catalog_options(options); let flavor = SourceFlavor::from_path_with_options(path, &effective)?; let source_raw = std::fs::read_to_string(path)?; let loaded = load_units_for_source_file(path, flavor, &source_raw, &effective)?; diff --git a/src/compiler/semantic_model.rs b/src/compiler/semantic_model.rs index 96cbe727..3de33105 100644 --- a/src/compiler/semantic_model.rs +++ b/src/compiler/semantic_model.rs @@ -48,7 +48,7 @@ use std::sync::Arc; use crate::host_api::{ HostApiCatalog, HostApiFingerprint, HostFunctionSchema, HostImportParam, HostImportSchema, - HostParamPassing, HostTypeSchema, + HostParamPassing, HostStructSchema, HostTypeSchema, }; use super::CompileError; @@ -627,7 +627,7 @@ impl SemanticModel { /// Convert a compiler [`TypeSchema`] to a [`HostTypeSchema`] for display. fn compiler_schema_to_host_schema(&self, schema: &TypeSchema) -> HostTypeSchema { match schema { - TypeSchema::Unknown => HostTypeSchema::Unknown, + TypeSchema::Unknown => crate::host_api::HostTypeSchema::Unknown, TypeSchema::Null => HostTypeSchema::Null, TypeSchema::Int => HostTypeSchema::Int, TypeSchema::Float => HostTypeSchema::Float, @@ -652,15 +652,24 @@ impl SemanticModel { result: Box::new(self.compiler_schema_to_host_schema(result)), }, TypeSchema::Resource(key) => HostTypeSchema::Resource(key.clone()), - TypeSchema::Named(_name, _type_args) => HostTypeSchema::Unknown, - TypeSchema::GenericParam(_name) => HostTypeSchema::Unknown, + TypeSchema::Named(name, _type_args) => self + .catalog + .struct_named(name) + .map(HostStructSchema::as_type) + .unwrap_or_else(|| HostTypeSchema::Named { + name: name.clone(), + fields: Vec::new(), + }), + TypeSchema::GenericParam(_name) => crate::host_api::HostTypeSchema::Unknown, TypeSchema::ArrayTuple(_items) => { - HostTypeSchema::Array(Box::new(HostTypeSchema::Unknown)) + HostTypeSchema::Array(Box::new(crate::host_api::HostTypeSchema::Unknown)) } TypeSchema::ArrayTupleRest { prefix: _, rest: _ } => { - HostTypeSchema::Array(Box::new(HostTypeSchema::Unknown)) + HostTypeSchema::Array(Box::new(crate::host_api::HostTypeSchema::Unknown)) + } + TypeSchema::Object(_) => { + HostTypeSchema::Map(Box::new(crate::host_api::HostTypeSchema::Unknown)) } - TypeSchema::Object(_) => HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)), } } @@ -1796,7 +1805,7 @@ mod tests { "len", vec![HostParamSchema::value( "value", - HostTypeSchema::Array(Box::new(HostTypeSchema::Unknown)), + HostTypeSchema::Array(Box::new(crate::host_api::HostTypeSchema::Unknown)), )], HostTypeSchema::Int, )); @@ -1813,7 +1822,7 @@ mod tests { "len", vec![HostParamSchema::value( "value", - HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)), + HostTypeSchema::Map(Box::new(crate::host_api::HostTypeSchema::Unknown)), )], HostTypeSchema::Int, )); @@ -2466,6 +2475,8 @@ mod tests { return_type: TypeSchema::Resource(ResourceTypeKey::new("test.resource").unwrap()), passing: vec![HostParamPassing::Value], fingerprint: catalog.fingerprint(), + host_params: Vec::new(), + host_return_type: crate::host_api::HostTypeSchema::Unknown, }; ir.stmts.push(Stmt::Expr { expr: Expr::Call( diff --git a/src/compiler/typing/context.rs b/src/compiler/typing/context.rs index 15a18668..2c516070 100644 --- a/src/compiler/typing/context.rs +++ b/src/compiler/typing/context.rs @@ -3285,6 +3285,8 @@ mod tests { return_type, passing: vec![HostParamPassing::Value], fingerprint: fingerprint(0x88), + host_params: Vec::new(), + host_return_type: crate::host_api::HostTypeSchema::Unknown, } } diff --git a/src/host_api.rs b/src/host_api.rs index e4105323..5af8e903 100644 --- a/src/host_api.rs +++ b/src/host_api.rs @@ -10,8 +10,9 @@ //! ## Design invariants //! //! * **Host-agnostic.** The catalog carries only semantic signatures: scalar, -//! collection, callable and unknown schemas plus typed resource references. -//! It does not talk about handles, bytecode or VM state. +//! collection, callable, named fixed-shape structs and unknown schemas plus +//! typed resource references. It does not talk about handles, bytecode or VM +//! state. Named structs are compile-time shapes; runtime values remain maps. //! * **Owned and serializable-friendly.** Every type owns its data (`String` / //! `Vec`) and derives or implements [`serde::Serialize`] / //! [`serde::Deserialize`]. No lifetimes, no `&'static` slices, no @@ -22,9 +23,9 @@ //! cannot enter through serde — the same rules the builder enforces. //! * **Explicit resource ownership.** A parameter whose type **contains any //! resource**, directly or recursively (`Optional`, `Array`, `Map`, -//! `Callable`), must use an explicit borrow/ownership passing mode; `Value` -//! is forbidden. A parameter whose type contains **no** resource must use -//! `Value`; a borrow/ownership mode is forbidden. +//! `Callable`, named struct fields), must use an explicit borrow/ownership +//! passing mode; `Value` is forbidden. A parameter whose type contains **no** +//! resource must use `Value`; a borrow/ownership mode is forbidden. //! * **Overloading.** Host functions may legally share a name with distinct //! argument signatures (standard builtins such as `len` dispatch for string, //! array, bytes and map). Overloads must differ in their **argument type / @@ -85,12 +86,18 @@ pub const MAX_HOST_CATALOG_RESOURCES: usize = 1_024; /// Maximum function/overload declarations in one catalog. pub const MAX_HOST_CATALOG_FUNCTIONS: usize = 1_024; +/// Maximum named-struct declarations in one catalog. +pub const MAX_HOST_CATALOG_STRUCTS: usize = 1_024; + /// Maximum byte length of names on host parameter records. pub const MAX_HOST_PARAMETER_NAME_LEN: usize = 128; /// Maximum byte length of host resource/function documentation. pub const MAX_HOST_DESCRIPTION_LEN: usize = 4_096; +/// Max byte length of a validated named-struct or struct-field identifier. +const MAX_STRUCT_IDENT_LEN: usize = 128; + /// 8-byte domain magic prepended to every fingerprint so digest bytes in one /// domain (host API catalogs) cannot be confused with unrelated FNV digests /// produced by other tooling. @@ -99,7 +106,7 @@ const FINGERPRINT_DOMAIN_MAGIC: &[u8; 8] = b"rss-hapi"; /// The fingerprint wire/format version. Bump whenever the canonical byte /// encoding or semantic interpretation changes so old and new digests are /// never compared across versions. -const FINGERPRINT_FORMAT_VERSION: u8 = 1; +const FINGERPRINT_FORMAT_VERSION: u8 = 2; /// Error returned when a [`ResourceTypeKey`] cannot be constructed. #[derive(Clone, Debug, PartialEq, Eq)] @@ -338,11 +345,71 @@ impl HostParamPassing { } } +/// One field of a named host struct. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct HostStructField { + /// Field identifier. + pub name: String, + /// Field type. + pub ty: HostTypeSchema, +} + +impl HostStructField { + /// Constructs a named field. + pub fn new(name: impl Into, ty: HostTypeSchema) -> Self { + Self { + name: name.into(), + ty, + } + } +} + +/// Catalog-level named fixed-shape host struct. +/// +/// Functions reference the same shape via [`HostTypeSchema::Named`]. Runtime +/// values remain maps; the name is a compile-time identity used for field +/// access, object-literal compatibility, display, and LSP. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct HostStructSchema { + /// Struct type name (identifier). + pub name: String, + /// Declared fields in registration order. + pub fields: Vec, + /// Human-readable documentation. Excluded from the fingerprint. + pub description: String, +} + +impl HostStructSchema { + /// Constructs a named struct with empty documentation. + pub fn new(name: impl Into, fields: Vec) -> Self { + Self { + name: name.into(), + fields, + description: String::new(), + } + } + + /// Sets the documentation string. + pub fn with_description(mut self, description: impl Into) -> Self { + self.description = description.into(); + self + } + + /// Type used in function params/returns for this struct. + pub fn as_type(&self) -> HostTypeSchema { + HostTypeSchema::Named { + name: self.name.clone(), + fields: self.fields.clone(), + } + } +} + /// Semantic schema of a single host value type. /// /// Covers the same scalar / collection / callable / unknown surface used by /// the compiler's inference pass, and adds an explicit [`Self::Resource`] -/// variant that references a declared [`ResourceTypeKey`]. +/// variant that references a declared [`ResourceTypeKey`] plus +/// [`Self::Named`] for catalog-declared fixed-shape structs. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum HostTypeSchema { Unknown, @@ -362,9 +429,22 @@ pub enum HostTypeSchema { }, /// A host resource identified by a declared [`ResourceTypeKey`]. Resource(ResourceTypeKey), + /// Named fixed-shape struct. Runtime values remain maps. + Named { + name: String, + fields: Vec, + }, } impl HostTypeSchema { + /// Named fixed-shape struct used in function params/returns. + pub fn named_struct(name: impl Into, fields: Vec) -> Self { + Self::Named { + name: name.into(), + fields, + } + } + /// Returns the resource key when this schema (directly, or wrapped in a /// single optional layer) denotes a host resource. This is a shallow /// helper; use [`Self::contains_resource`] for the full recursive test. @@ -388,8 +468,8 @@ impl HostTypeSchema { } /// Whether this schema references at least one resource, anywhere in the - /// tree (direct, `Optional`, `Array`, `Map` value, or inside a `Callable` - /// parameter/result). + /// tree (direct, `Optional`, `Array`, `Map` value, named struct field, or + /// inside a `Callable` parameter/result). pub fn contains_resource(&self) -> bool { let mut budget = ComplexityBudget::default(); let mut on_resource = |_key: &ResourceTypeKey| {}; @@ -438,6 +518,13 @@ impl Serialize for HostTypeSchema { Self::Resource(key) => { serializer.serialize_newtype_variant("HostTypeSchema", 12, "Resource", key) } + Self::Named { name, fields } => { + let mut state = + serializer.serialize_struct_variant("HostTypeSchema", 13, "Named", 2)?; + state.serialize_field("name", name)?; + state.serialize_field("fields", fields)?; + state.end() + } } } } @@ -457,6 +544,7 @@ enum HostTypeSchemaVariant { Optional, Callable, Resource, + Named, } struct HostTypeSchemaSeed<'a> { @@ -489,7 +577,7 @@ impl<'de> DeserializeSeed<'de> for HostTypeSchemaSeed<'_> { "HostTypeSchema", &[ "Unknown", "Null", "Int", "Float", "Number", "Bool", "String", "Bytes", "Array", - "Map", "Optional", "Callable", "Resource", + "Map", "Optional", "Callable", "Resource", "Named", ], HostTypeSchemaVisitor { budget: self.budget, @@ -566,6 +654,12 @@ impl<'de> Visitor<'de> for HostTypeSchemaVisitor<'_> { HostTypeSchemaVariant::Resource => access .newtype_variant::() .map(HostTypeSchema::Resource), + HostTypeSchemaVariant::Named => access + .newtype_variant_seed(NamedSchemaSeed { + budget: self.budget, + depth: self.depth, + }) + .map(|(name, fields)| HostTypeSchema::Named { name, fields }), } } } @@ -680,6 +774,239 @@ impl<'de> Visitor<'de> for CallableSchemaVisitor<'_> { } } +struct NamedSchemaSeed<'a> { + budget: &'a mut ComplexityBudget, + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for NamedSchemaSeed<'_> { + type Value = (String, Vec); + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let child_depth = next_schema_depth::(self.depth)?; + deserializer.deserialize_struct( + "HostTypeSchema::Named", + &["name", "fields"], + NamedSchemaVisitor { + budget: self.budget, + child_depth, + }, + ) + } +} + +struct NamedSchemaVisitor<'a> { + budget: &'a mut ComplexityBudget, + child_depth: usize, +} + +#[derive(Deserialize)] +#[serde(field_identifier, rename_all = "snake_case")] +enum NamedSchemaField { + Name, + Fields, +} + +impl<'de> Visitor<'de> for NamedSchemaVisitor<'_> { + type Value = (String, Vec); + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a named struct schema object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + bounded_map_size_hint(map.size_hint(), "named schema", 2)?; + let mut entries = 0; + let mut name = None; + let mut fields = None; + loop { + let Some(field) = map.next_key::()? else { + break; + }; + bounded_map_entry(&mut entries, "named schema", 2)?; + match field { + NamedSchemaField::Name => { + if name.is_some() { + return Err(de::Error::duplicate_field("name")); + } + name = Some(map.next_value_seed(BoundedStringSeed { + field: "struct name", + limit: MAX_STRUCT_IDENT_LEN, + })?); + } + NamedSchemaField::Fields => { + if fields.is_some() { + return Err(de::Error::duplicate_field("fields")); + } + fields = Some(map.next_value_seed(HostStructFieldListSeed { + budget: self.budget, + depth: self.child_depth, + })?); + } + } + } + let name = name.ok_or_else(|| de::Error::missing_field("name"))?; + let fields = fields.ok_or_else(|| de::Error::missing_field("fields"))?; + Ok((name, fields)) + } +} + +struct HostStructFieldListSeed<'a> { + budget: &'a mut ComplexityBudget, + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for HostStructFieldListSeed<'_> { + type Value = Vec; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_seq(HostStructFieldListVisitor { + budget: self.budget, + depth: self.depth, + }) + } +} + +struct HostStructFieldListVisitor<'a> { + budget: &'a mut ComplexityBudget, + depth: usize, +} + +impl<'de> Visitor<'de> for HostStructFieldListVisitor<'_> { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded named-struct field list") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let hint = seq.size_hint(); + let capacity = bounded_sequence_capacity( + hint, + MAX_HOST_SCHEMA_PROPERTIES - self.budget.properties, + HostSchemaValidationError::PropertyBudgetExceeded { + limit: MAX_HOST_SCHEMA_PROPERTIES, + }, + )?; + let mut values = Vec::new(); + if capacity != 0 { + values.try_reserve_exact(capacity).map_err(|_| { + de::Error::custom(HostSchemaValidationError::AllocationFailed { + field: "named struct fields", + }) + })?; + } + while let Some(value) = seq.next_element_seed(HostStructFieldSeed { + budget: self.budget, + depth: self.depth, + })? { + values.try_reserve_exact(1).map_err(|_| { + de::Error::custom(HostSchemaValidationError::AllocationFailed { + field: "named struct fields", + }) + })?; + values.push(value); + } + Ok(values) + } +} + +struct HostStructFieldSeed<'a> { + budget: &'a mut ComplexityBudget, + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for HostStructFieldSeed<'_> { + type Value = HostStructField; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_struct( + "HostStructField", + &["name", "ty"], + HostStructFieldVisitor { + budget: self.budget, + depth: self.depth, + }, + ) + } +} + +struct HostStructFieldVisitor<'a> { + budget: &'a mut ComplexityBudget, + depth: usize, +} + +#[derive(Deserialize)] +#[serde(field_identifier, rename_all = "snake_case")] +enum HostStructFieldDeField { + Name, + Ty, +} + +impl<'de> Visitor<'de> for HostStructFieldVisitor<'_> { + type Value = HostStructField; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a named struct field") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + bounded_map_size_hint(map.size_hint(), "named struct field", 2)?; + let mut entries = 0; + let mut name = None; + let mut ty = None; + loop { + let Some(field) = map.next_key::()? else { + break; + }; + bounded_map_entry(&mut entries, "named struct field", 2)?; + match field { + HostStructFieldDeField::Name => { + if name.is_some() { + return Err(de::Error::duplicate_field("name")); + } + name = Some(map.next_value_seed(BoundedStringSeed { + field: "struct field name", + limit: MAX_STRUCT_IDENT_LEN, + })?); + } + HostStructFieldDeField::Ty => { + if ty.is_some() { + return Err(de::Error::duplicate_field("ty")); + } + ty = Some(map.next_value_seed(HostTypeSchemaSeed { + budget: self.budget, + depth: self.depth, + property: true, + })?); + } + } + } + Ok(HostStructField { + name: name.ok_or_else(|| de::Error::missing_field("name"))?, + ty: ty.ok_or_else(|| de::Error::missing_field("ty"))?, + }) + } +} + struct HostSchemaListSeed<'a> { budget: &'a mut ComplexityBudget, depth: usize, @@ -773,6 +1100,7 @@ impl fmt::Display for HostTypeSchema { write!(f, ") -> {result}") } Self::Resource(key) => write!(f, "resource<{key}>"), + Self::Named { name, .. } => write!(f, "{name}"), } } } @@ -1800,6 +2128,9 @@ pub enum HostSchemaValidationError { FunctionBudgetExceeded { limit: usize, }, + StructBudgetExceeded { + limit: usize, + }, MapEntriesExceeded { field: &'static str, limit: usize, @@ -1845,6 +2176,9 @@ impl fmt::Display for HostSchemaValidationError { Self::FunctionBudgetExceeded { limit } => { write!(f, "host catalog function budget exceeds maximum of {limit}") } + Self::StructBudgetExceeded { limit } => { + write!(f, "host catalog struct budget exceeds maximum of {limit}") + } Self::MapEntriesExceeded { field, limit } => { write!(f, "host {field} map contains more than {limit} entries") } @@ -1873,6 +2207,7 @@ struct ComplexityBudget { parameters: usize, resources: usize, functions: usize, + structs: usize, } impl ComplexityBudget { @@ -1935,6 +2270,18 @@ impl ComplexityBudget { )?; Ok(()) } + + fn charge_structs(&mut self, amount: usize) -> Result<(), HostSchemaValidationError> { + self.structs = checked_budget_add( + self.structs, + amount, + MAX_HOST_CATALOG_STRUCTS, + HostSchemaValidationError::StructBudgetExceeded { + limit: MAX_HOST_CATALOG_STRUCTS, + }, + )?; + Ok(()) + } } fn checked_budget_add( @@ -2082,6 +2429,23 @@ where pending.push((param, child_depth)); } } + HostTypeSchema::Named { fields, .. } => { + budget.charge_properties(fields.len())?; + let child_depth = + depth + .checked_add(1) + .ok_or(HostSchemaValidationError::IntegerOverflow { + field: "schema depth", + })?; + pending.try_reserve(fields.len()).map_err(|_| { + HostSchemaValidationError::AllocationFailed { + field: "schema traversal", + } + })?; + for field in fields.iter().rev() { + pending.push((&field.ty, child_depth)); + } + } HostTypeSchema::Unknown | HostTypeSchema::Null | HostTypeSchema::Int @@ -2236,6 +2600,35 @@ pub enum HostApiCatalogError { parameter: String, }, SchemaValidation(HostSchemaValidationError), + DuplicateStructName { + name: String, + }, + InvalidStructName { + name: String, + reason: String, + }, + DuplicateStructField { + struct_name: String, + field: String, + }, + InvalidStructFieldName { + struct_name: String, + field: String, + reason: String, + }, + UnknownStructReference { + function: String, + name: String, + }, + StructFieldMismatch { + function: String, + name: String, + }, + /// A named struct field referenced an undeclared resource type. + UnknownStructResourceReference { + struct_name: String, + key: ResourceTypeKey, + }, } impl fmt::Display for HostApiCatalogError { @@ -2280,7 +2673,38 @@ impl fmt::Display for HostApiCatalogError { by `Value`; an explicit Borrow/BorrowMut/TakeOwned is required", ), Self::SchemaValidation(error) => error.fmt(f), - } + Self::DuplicateStructName { name } => { + write!(f, "duplicate named host struct `{name}`") + } + Self::InvalidStructName { name, reason } => { + write!(f, "invalid named host struct `{name}`: {reason}") + } + Self::DuplicateStructField { struct_name, field } => write!( + f, + "named host struct `{struct_name}` declares duplicate field `{field}`" + ), + Self::InvalidStructFieldName { + struct_name, + field, + reason, + } => write!( + f, + "named host struct `{struct_name}` has invalid field `{field}`: {reason}" + ), + Self::UnknownStructReference { function, name } => write!( + f, + "undeclared named struct `{name}` referenced from `{function}`" + ), + Self::StructFieldMismatch { function, name } => write!( + f, + "host function `{function}` uses named struct `{name}` with fields that do not \ + match the catalog declaration" + ), + Self::UnknownStructResourceReference { struct_name, key } => write!( + f, + "named host struct `{struct_name}` references undeclared resource type `{key}`" + ), + } } } @@ -2300,6 +2724,7 @@ impl std::error::Error for HostApiCatalogError {} #[derive(Clone, Debug, PartialEq, Eq)] pub struct HostApiCatalog { resources: Vec, + structs: Vec, functions: Vec, } @@ -2353,8 +2778,9 @@ impl Serialize for HostApiCatalog { S: serde::Serializer, { self.validate().map_err(serde::ser::Error::custom)?; - let mut state = serializer.serialize_struct("HostApiCatalog", 2)?; + let mut state = serializer.serialize_struct("HostApiCatalog", 3)?; state.serialize_field("resources", &self.resources)?; + state.serialize_field("structs", &self.structs)?; state.serialize_field("functions", &self.functions)?; state.end() } @@ -2364,6 +2790,7 @@ impl Serialize for HostApiCatalog { #[serde(field_identifier, rename_all = "snake_case")] enum HostApiCatalogField { Resources, + Structs, Functions, } @@ -2526,6 +2953,84 @@ impl<'de> Visitor<'de> for CatalogFunctionListVisitor<'_> { } } +struct CatalogStructSeed<'a> { + budget: &'a mut ComplexityBudget, +} + +impl<'de> DeserializeSeed<'de> for CatalogStructSeed<'_> { + type Value = HostStructSchema; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + self.budget.charge_structs(1).map_err(de::Error::custom)?; + HostStructSchema::deserialize(deserializer) + } +} + +struct CatalogStructListSeed<'a> { + budget: &'a mut ComplexityBudget, +} + +impl<'de> DeserializeSeed<'de> for CatalogStructListSeed<'_> { + type Value = Vec; + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_seq(CatalogStructListVisitor { + budget: self.budget, + }) + } +} + +struct CatalogStructListVisitor<'a> { + budget: &'a mut ComplexityBudget, +} + +impl<'de> Visitor<'de> for CatalogStructListVisitor<'_> { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a bounded catalog struct list") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let hint = seq.size_hint(); + let capacity = bounded_sequence_capacity( + hint, + MAX_HOST_CATALOG_STRUCTS - self.budget.structs, + HostSchemaValidationError::StructBudgetExceeded { + limit: MAX_HOST_CATALOG_STRUCTS, + }, + )?; + let mut values = Vec::new(); + if capacity != 0 { + values.try_reserve_exact(capacity).map_err(|_| { + de::Error::custom(HostSchemaValidationError::AllocationFailed { + field: "catalog structs", + }) + })?; + } + while let Some(value) = seq.next_element_seed(CatalogStructSeed { + budget: self.budget, + })? { + values.try_reserve_exact(1).map_err(|_| { + de::Error::custom(HostSchemaValidationError::AllocationFailed { + field: "catalog structs", + }) + })?; + values.push(value); + } + Ok(values) + } +} + struct HostApiCatalogVisitor<'a> { budget: &'a mut ComplexityBudget, } @@ -2541,15 +3046,16 @@ impl<'de> Visitor<'de> for HostApiCatalogVisitor<'_> { where A: MapAccess<'de>, { - bounded_map_size_hint(map.size_hint(), "host API catalog", 2)?; + bounded_map_size_hint(map.size_hint(), "host API catalog", 3)?; let mut entries = 0; let mut resources = None; + let mut structs = None; let mut functions = None; loop { let Some(field) = map.next_key::()? else { break; }; - bounded_map_entry(&mut entries, "host API catalog", 2)?; + bounded_map_entry(&mut entries, "host API catalog", 3)?; match field { HostApiCatalogField::Resources => { if resources.is_some() { @@ -2559,6 +3065,14 @@ impl<'de> Visitor<'de> for HostApiCatalogVisitor<'_> { budget: self.budget, })?); } + HostApiCatalogField::Structs => { + if structs.is_some() { + return Err(de::Error::duplicate_field("structs")); + } + structs = Some(map.next_value_seed(CatalogStructListSeed { + budget: self.budget, + })?); + } HostApiCatalogField::Functions => { if functions.is_some() { return Err(de::Error::duplicate_field("functions")); @@ -2571,6 +3085,7 @@ impl<'de> Visitor<'de> for HostApiCatalogVisitor<'_> { } HostApiBuilder { resources: resources.ok_or_else(|| de::Error::missing_field("resources"))?, + structs: structs.unwrap_or_default(), functions: functions.ok_or_else(|| de::Error::missing_field("functions"))?, } .build() @@ -2586,7 +3101,7 @@ impl<'de> Deserialize<'de> for HostApiCatalog { let mut budget = ComplexityBudget::default(); deserializer.deserialize_struct( "HostApiCatalog", - &["resources", "functions"], + &["resources", "structs", "functions"], HostApiCatalogVisitor { budget: &mut budget, }, @@ -2603,6 +3118,7 @@ impl<'de> Deserialize<'de> for HostApiCatalog { #[derive(Clone, Debug, Default)] pub struct HostApiBuilder { resources: Vec, + structs: Vec, functions: Vec, } @@ -2674,6 +3190,16 @@ impl HostApiCatalog { &self.resources } + /// Looks up a declared named struct by name. + pub fn struct_named(&self, name: &str) -> Option<&HostStructSchema> { + self.structs.iter().find(|schema| schema.name == name) + } + + /// All declared named structs (in registration order). + pub fn structs(&self) -> &[HostStructSchema] { + &self.structs + } + /// All host functions (in registration order). pub fn functions(&self) -> &[HostFunctionSchema] { &self.functions @@ -2682,11 +3208,12 @@ impl HostApiCatalog { /// Validates this catalog with the same bounded traversal used by the /// builder and all identity paths. pub fn validate(&self) -> Result<(), HostApiCatalogError> { - validate_surface(&self.resources, &self.functions) + validate_surface(&self.resources, &self.structs, &self.functions) } /// Canonical semantic bytes for the whole catalog: `FINGERPRINT_DOMAIN_MAGIC` /// ++ `FINGERPRINT_FORMAT_VERSION` ++ resources (sorted by key) ++ + /// named structs (sorted by name, fields sorted by field name) ++ /// functions (sorted by full semantic signature bytes). fn canonical_bytes(&self) -> Vec { self.try_canonical_bytes() @@ -2709,6 +3236,17 @@ impl HostApiCatalog { push_len_str(&mut bytes, resource.key.as_str())?; } + // Named structs sorted by name; fields sorted by field name so + // registration order does not affect the digest. Descriptions are + // excluded. + let mut structs: Vec<&HostStructSchema> = self.structs.iter().collect(); + structs.sort_by(|a, b| a.name.cmp(&b.name)); + push_tag(&mut bytes, b'T'); + push_len(&mut bytes, structs.len())?; + for schema in &structs { + push_struct_def(&mut bytes, schema)?; + } + // Functions sorted by their full canonical semantic signature bytes so // overloaded registration order is irrelevant (exact duplicates are // already rejected at build time). @@ -2729,11 +3267,11 @@ impl HostApiCatalog { /// Deterministic, order-independent fingerprint of the semantic contents. /// - /// The fingerprint covers resource keys and every function’s name, - /// parameter (name, type, passing mode) and return type. It excludes - /// documentation and registration order. See the module doc for the - /// security caveat: this 64-bit FNV digest is equality / change-detection - /// only, never authentication. + /// The fingerprint covers resource keys, named-struct names and field + /// types, and every function’s name, parameter (name, type, passing mode) + /// and return type. It excludes documentation and registration order. See + /// the module doc for the security caveat: this 64-bit FNV digest is + /// equality / change-detection only, never authentication. pub fn fingerprint(&self) -> HostApiFingerprint { HostApiFingerprint(fnv1a(&self.canonical_bytes())) } @@ -2776,6 +3314,7 @@ where /// builder and the serde path so both reject the same malformed inputs. fn validate_surface( resources: &[ResourceTypeSchema], + structs: &[HostStructSchema], functions: &[HostFunctionSchema], ) -> Result<(), HostApiCatalogError> { let mut budget = ComplexityBudget::default(); @@ -2785,6 +3324,9 @@ fn validate_surface( budget .charge_functions(functions.len()) .map_err(HostApiCatalogError::from)?; + budget + .charge_structs(structs.len()) + .map_err(HostApiCatalogError::from)?; for resource in resources { validate_description(&resource.description).map_err(HostApiCatalogError::from)?; @@ -2799,6 +3341,8 @@ fn validate_surface( } } + validate_structs(resources, structs)?; + // Per-function invariants. for function in functions { // Preserve the catalog-specific name error for callers that already @@ -2880,6 +3424,11 @@ fn validate_surface( key, }); } + + validate_named_struct_refs(&function.name, &function.return_type, structs)?; + for param in &function.params { + validate_named_struct_refs(&function.name, ¶m.ty, structs)?; + } } // Reject ambiguous overloads: two functions sharing a name and an identical @@ -2918,6 +3467,11 @@ impl HostApiBuilder { self.resources.push(resource); } + /// Registers a named fixed-shape host struct. + pub fn named_struct(&mut self, schema: HostStructSchema) { + self.structs.push(schema); + } + /// Registers a host function signature. Same-name functions with distinct /// signatures (overloads) are allowed. pub fn function(&mut self, function: HostFunctionSchema) { @@ -2936,9 +3490,10 @@ impl HostApiBuilder { /// Validates and freezes the catalog. pub fn build(self) -> Result { - validate_surface(&self.resources, &self.functions)?; + validate_surface(&self.resources, &self.structs, &self.functions)?; Ok(HostApiCatalog { resources: self.resources, + structs: self.structs, functions: self.functions, }) } @@ -3034,11 +3589,156 @@ fn try_push_type( push_tag(bytes, b'r'); push_len_str(bytes, key.as_str())?; } + HostTypeSchema::Named { name, fields } => { + push_tag(bytes, b'n'); + push_len_str(bytes, name)?; + push_named_fields(bytes, fields)?; + } + } + } + Ok(()) +} + +fn push_named_fields( + bytes: &mut Vec, + fields: &[HostStructField], +) -> Result<(), HostSchemaValidationError> { + let mut fields: Vec<&HostStructField> = fields.iter().collect(); + fields.sort_by(|a, b| a.name.cmp(&b.name)); + push_len(bytes, fields.len())?; + for field in fields { + push_len_str(bytes, &field.name)?; + try_push_type(bytes, &field.ty)?; + } + Ok(()) +} + +fn push_struct_def( + bytes: &mut Vec, + schema: &HostStructSchema, +) -> Result<(), HostSchemaValidationError> { + push_len_str(bytes, &schema.name)?; + push_named_fields(bytes, &schema.fields) +} + +fn validate_struct_ident(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("must not be empty".to_string()); + } + if name.len() > MAX_STRUCT_IDENT_LEN { + return Err(format!( + "is {} bytes; the maximum is {MAX_STRUCT_IDENT_LEN}", + name.len() + )); + } + let mut chars = name.chars(); + let first = chars.next().expect("name is non-empty"); + if !first.is_ascii_alphabetic() && first != '_' { + return Err("must start with an ASCII letter or '_'".to_string()); + } + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err("must be ASCII alphanumeric or '_'".to_string()); + } + Ok(()) +} + +fn validate_structs( + resources: &[ResourceTypeSchema], + structs: &[HostStructSchema], +) -> Result<(), HostApiCatalogError> { + for (i, schema) in structs.iter().enumerate() { + if structs[..i].iter().any(|prior| prior.name == schema.name) { + return Err(HostApiCatalogError::DuplicateStructName { + name: schema.name.clone(), + }); + } + if let Err(reason) = validate_struct_ident(&schema.name) { + return Err(HostApiCatalogError::InvalidStructName { + name: schema.name.clone(), + reason, + }); + } + for (j, field) in schema.fields.iter().enumerate() { + if schema.fields[..j] + .iter() + .any(|prior| prior.name == field.name) + { + return Err(HostApiCatalogError::DuplicateStructField { + struct_name: schema.name.clone(), + field: field.name.clone(), + }); + } + if let Err(reason) = validate_struct_ident(&field.name) { + return Err(HostApiCatalogError::InvalidStructFieldName { + struct_name: schema.name.clone(), + field: field.name.clone(), + reason, + }); + } + let mut keys = Vec::new(); + field.ty.collect_resource_keys(&mut keys); + for key in keys { + if !resources.iter().any(|resource| &resource.key == key) { + return Err(HostApiCatalogError::UnknownStructResourceReference { + struct_name: schema.name.clone(), + key: key.clone(), + }); + } + } + validate_named_struct_refs(&schema.name, &field.ty, structs)?; } } Ok(()) } +fn struct_fields_equivalent(left: &[HostStructField], right: &[HostStructField]) -> bool { + if left.len() != right.len() { + return false; + } + let mut left_sorted: Vec<_> = left.iter().map(|field| (&field.name, &field.ty)).collect(); + let mut right_sorted: Vec<_> = right.iter().map(|field| (&field.name, &field.ty)).collect(); + left_sorted.sort_by(|a, b| a.0.cmp(b.0)); + right_sorted.sort_by(|a, b| a.0.cmp(b.0)); + left_sorted == right_sorted +} + +fn validate_named_struct_refs( + function: &str, + schema: &HostTypeSchema, + structs: &[HostStructSchema], +) -> Result<(), HostApiCatalogError> { + match schema { + HostTypeSchema::Named { name, fields } => { + let Some(declared) = structs.iter().find(|schema| schema.name == *name) else { + return Err(HostApiCatalogError::UnknownStructReference { + function: function.to_string(), + name: name.clone(), + }); + }; + if !struct_fields_equivalent(&declared.fields, fields) { + return Err(HostApiCatalogError::StructFieldMismatch { + function: function.to_string(), + name: name.clone(), + }); + } + for field in fields { + validate_named_struct_refs(function, &field.ty, structs)?; + } + Ok(()) + } + HostTypeSchema::Array(inner) + | HostTypeSchema::Map(inner) + | HostTypeSchema::Optional(inner) => validate_named_struct_refs(function, inner, structs), + HostTypeSchema::Callable { params, result } => { + for param in params { + validate_named_struct_refs(function, param, structs)?; + } + validate_named_struct_refs(function, result, structs) + } + _ => Ok(()), + } +} + fn invalid_schema_bytes(error: &HostSchemaValidationError) -> Vec { let mut bytes = b"invalid-host-schema:".to_vec(); bytes.extend_from_slice(error.to_string().as_bytes()); @@ -3645,8 +4345,8 @@ mod tests { } #[test] - fn fingerprint_version_is_one() { - assert_eq!(FINGERPRINT_FORMAT_VERSION, 1); + fn fingerprint_version_is_two() { + assert_eq!(FINGERPRINT_FORMAT_VERSION, 2); } #[test] @@ -4167,6 +4867,405 @@ mod tests { ); } + // --- Named host structs --- + + fn point_fields() -> Vec { + vec![ + HostStructField::new("x", HostTypeSchema::Int), + HostStructField::new("y", HostTypeSchema::Int), + ] + } + + fn point_struct() -> HostStructSchema { + HostStructSchema::new("Point", point_fields()).with_description("A 2D point") + } + + fn point_type() -> HostTypeSchema { + point_struct().as_type() + } + + #[test] + fn named_struct_can_be_declared_and_used_as_param_and_return() { + let mut builder = HostApiCatalog::builder(); + builder.named_struct(point_struct()); + builder.function(HostFunctionSchema::with_return( + "make_point", + vec![ + HostParamSchema::value("x", HostTypeSchema::Int), + HostParamSchema::value("y", HostTypeSchema::Int), + ], + point_type(), + )); + builder.function(HostFunctionSchema::with_return( + "take_point", + vec![HostParamSchema::value("p", point_type())], + HostTypeSchema::Int, + )); + let catalog = builder.build().expect("named struct catalog must build"); + assert_eq!(catalog.structs().len(), 1); + assert_eq!(catalog.struct_named("Point").expect("Point").name, "Point"); + assert_eq!( + catalog + .function("make_point") + .expect("make_point") + .return_type, + point_type() + ); + assert_eq!( + catalog.function("take_point").expect("take_point").params[0].ty, + point_type() + ); + } + + #[test] + fn named_struct_display_is_the_struct_name() { + assert_eq!(format!("{}", point_type()), "Point"); + assert_eq!( + format!("{}", HostTypeSchema::Optional(Box::new(point_type()))), + "optional" + ); + } + + #[test] + fn named_struct_keeps_dynamic_map_distinct() { + let named = point_type(); + let dynamic = HostTypeSchema::Map(Box::new(HostTypeSchema::Int)); + assert_ne!(named, dynamic); + assert!(!named.contains_resource()); + assert!(!dynamic.contains_resource()); + } + + #[test] + fn nested_resource_in_named_struct_requires_explicit_passing() { + let handle = HostStructSchema::new( + "HandleBox", + vec![HostStructField::new( + "file", + HostTypeSchema::Resource(io_file_key()), + )], + ); + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.named_struct(handle.clone()); + builder.function(HostFunctionSchema::with_return( + "take_box", + vec![HostParamSchema::value("box", handle.as_type())], + HostTypeSchema::Null, + )); + assert_eq!( + builder.build(), + Err(HostApiCatalogError::ResourceValuePassing { + function: "take_box".to_string(), + parameter: "box".to_string(), + }) + ); + } + + #[test] + fn nested_resource_in_named_struct_borrow_is_allowed() { + let handle = HostStructSchema::new( + "HandleBox", + vec![HostStructField::new( + "file", + HostTypeSchema::Resource(io_file_key()), + )], + ); + let mut builder = HostApiCatalog::builder(); + builder.resource(io_file_resource()); + builder.named_struct(handle.clone()); + builder.function(HostFunctionSchema::with_return( + "borrow_box", + vec![HostParamSchema::with_passing( + "box", + handle.as_type(), + HostParamPassing::Borrow, + )], + HostTypeSchema::Null, + )); + builder + .build() + .expect("borrow of resource-bearing named struct is valid"); + } + + #[test] + fn undeclared_named_struct_reference_is_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.function(HostFunctionSchema::with_return( + "make_point", + vec![], + point_type(), + )); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::UnknownStructReference { .. }) + )); + } + + #[test] + fn duplicate_struct_name_is_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.named_struct(point_struct()); + builder.named_struct(point_struct()); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::DuplicateStructName { .. }) + )); + } + + #[test] + fn duplicate_struct_field_is_rejected() { + let mut builder = HostApiCatalog::builder(); + builder.named_struct(HostStructSchema::new( + "Dup", + vec![ + HostStructField::new("x", HostTypeSchema::Int), + HostStructField::new("x", HostTypeSchema::String), + ], + )); + assert!(matches!( + builder.build(), + Err(HostApiCatalogError::DuplicateStructField { .. }) + )); + } + + #[test] + fn named_struct_fingerprint_is_order_independent_and_excludes_docs() { + let mut a = HostApiCatalog::builder(); + a.named_struct(point_struct()); + a.function(HostFunctionSchema::with_return( + "make_point", + vec![], + point_type(), + )); + let catalog_a = a.build().expect("valid"); + + let mut b = HostApiCatalog::builder(); + b.function(HostFunctionSchema::with_return( + "make_point", + vec![], + HostTypeSchema::named_struct( + "Point", + vec![ + HostStructField::new("y", HostTypeSchema::Int), + HostStructField::new("x", HostTypeSchema::Int), + ], + ), + )); + b.named_struct( + HostStructSchema::new( + "Point", + vec![ + HostStructField::new("y", HostTypeSchema::Int), + HostStructField::new("x", HostTypeSchema::Int), + ], + ) + .with_description("docs must not affect fingerprint"), + ); + let catalog_b = b.build().expect("valid"); + assert_eq!(catalog_a.fingerprint(), catalog_b.fingerprint()); + + let mut c = HostApiCatalog::builder(); + c.named_struct(HostStructSchema::new( + "Point", + vec![ + HostStructField::new("x", HostTypeSchema::Int), + HostStructField::new("y", HostTypeSchema::Float), + ], + )); + c.function(HostFunctionSchema::with_return( + "make_point", + vec![], + HostTypeSchema::named_struct( + "Point", + vec![ + HostStructField::new("x", HostTypeSchema::Int), + HostStructField::new("y", HostTypeSchema::Float), + ], + ), + )); + let catalog_c = c.build().expect("valid"); + assert_ne!(catalog_a.fingerprint(), catalog_c.fingerprint()); + } + + #[test] + fn named_struct_canonical_bytes_are_deterministic() { + let mut builder = HostApiCatalog::builder(); + builder.named_struct(point_struct()); + builder.function(HostFunctionSchema::with_return( + "make_point", + vec![], + point_type(), + )); + let catalog = builder.build().expect("valid"); + let bytes = catalog.canonical_bytes(); + assert_eq!( + &bytes[..FINGERPRINT_DOMAIN_MAGIC.len()], + FINGERPRINT_DOMAIN_MAGIC + ); + assert_eq!(bytes[FINGERPRINT_DOMAIN_MAGIC.len()], 2); + assert_eq!(catalog.canonical_bytes(), bytes); + } + + #[test] + fn serde_round_trip_named_struct_catalog() { + let mut builder = HostApiCatalog::builder(); + builder.named_struct(point_struct()); + builder.function(HostFunctionSchema::with_return( + "make_point", + vec![], + point_type(), + )); + let catalog = builder.build().expect("valid"); + let json = serde_json::to_value(&catalog).expect("serialize"); + let back: HostApiCatalog = serde_json::from_value(json).expect("deserialize"); + assert_eq!(back.fingerprint(), catalog.fingerprint()); + assert_eq!(back.struct_named("Point").unwrap().fields.len(), 2); + } + + #[test] + fn serde_catalog_without_structs_field_still_loads() { + let catalog: HostApiCatalog = + serde_json::from_value(valid_catalog_json()).expect("legacy JSON should deserialize"); + assert!(catalog.structs().is_empty()); + } + + #[test] + fn nested_named_struct_is_validated_without_function_reference() { + let mut builder = HostApiCatalog::builder(); + builder.named_struct(HostStructSchema::new( + "Outer", + vec![HostStructField::new( + "inner", + HostTypeSchema::named_struct( + "Inner", + vec![HostStructField::new("x", HostTypeSchema::Int)], + ), + )], + )); + match builder.build() { + Err(err) => { + let text = err.to_string(); + match &err { + HostApiCatalogError::UnknownStructReference { name, .. } => { + assert_eq!(name, "Inner"); + } + other => { + panic!("undeclared nested named struct must be rejected, got {other:?}") + } + } + assert!( + !text.contains("host function `Outer`"), + "struct context should not be labeled as a host function: {text}" + ); + assert!( + text.contains("Inner") && text.contains("Outer"), + "display should name both the missing struct and its referrer, got {text}" + ); + } + other => panic!("undeclared nested named struct must be rejected, got {other:?}"), + } + } + + #[test] + fn nested_named_struct_field_mismatch_is_rejected_without_function() { + let mut builder = HostApiCatalog::builder(); + builder.named_struct(HostStructSchema::new( + "Inner", + vec![HostStructField::new("x", HostTypeSchema::Int)], + )); + builder.named_struct(HostStructSchema::new( + "Outer", + vec![HostStructField::new( + "inner", + HostTypeSchema::named_struct( + "Inner", + vec![HostStructField::new("x", HostTypeSchema::String)], + ), + )], + )); + match builder.build() { + Err(HostApiCatalogError::StructFieldMismatch { name, .. }) => { + assert_eq!(name, "Inner"); + } + other => panic!("nested named field shape must match declaration, got {other:?}"), + } + } + + #[test] + fn nested_named_struct_is_accepted_without_function_when_declared() { + let mut builder = HostApiCatalog::builder(); + builder.named_struct(HostStructSchema::new( + "Inner", + vec![HostStructField::new("x", HostTypeSchema::Int)], + )); + builder.named_struct(HostStructSchema::new( + "Outer", + vec![HostStructField::new( + "inner", + HostTypeSchema::named_struct( + "Inner", + vec![HostStructField::new("x", HostTypeSchema::Int)], + ), + )], + )); + builder + .build() + .expect("declared nested named struct is valid without a function"); + } + + #[test] + fn struct_field_mismatch_is_order_insensitive() { + let mut builder = HostApiCatalog::builder(); + builder.named_struct(HostStructSchema::new( + "Point", + vec![ + HostStructField::new("x", HostTypeSchema::Int), + HostStructField::new("y", HostTypeSchema::Int), + ], + )); + builder.function(HostFunctionSchema::with_return( + "take_point", + vec![HostParamSchema::value( + "p", + HostTypeSchema::named_struct( + "Point", + vec![ + HostStructField::new("y", HostTypeSchema::Int), + HostStructField::new("x", HostTypeSchema::Int), + ], + ), + )], + HostTypeSchema::Int, + )); + builder + .build() + .expect("named struct field order must not affect catalog matching"); + } + + #[test] + fn undeclared_resource_in_struct_field_names_struct_context() { + let mut builder = HostApiCatalog::builder(); + builder.named_struct(HostStructSchema::new( + "HandleBox", + vec![HostStructField::new( + "file", + HostTypeSchema::Resource(io_file_key()), + )], + )); + let err = builder + .build() + .expect_err("struct field resource must be declared"); + let text = err.to_string(); + assert!( + text.contains("HandleBox"), + "struct name should appear in the diagnostic, got {text}" + ); + assert!( + !text.contains("host function `HandleBox`"), + "struct-field resource errors should not pretend the struct is a function, got {text}" + ); + } + // --- helpers used by tests above --- fn len_overload(ty: HostTypeSchema) -> HostFunctionSchema { diff --git a/src/lib.rs b/src/lib.rs index b0283ab9..938b9fab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,11 +26,32 @@ pub use assembler::{AsmParseError, Assembler, AssemblerError, BytecodeBuilder, a pub use builtins::runtime::print::{PrintHostFunction, PrintlnHostFunction, format_value}; #[cfg(all(feature = "runtime", feature = "sqlite", not(target_arch = "wasm32")))] pub use builtins::runtime::sqlite::{SqliteHostExt, SqliteLimits, SqlitePolicy}; +#[cfg(all(feature = "runtime", feature = "sqlite", not(target_arch = "wasm32")))] +pub use builtins::runtime::{ + register_sqlite_builtin_module, register_sqlite_builtin_module_from_catalog, +}; #[cfg(feature = "runtime")] pub(crate) fn install_default_host_functions(registry: &mut vm::HostFunctionRegistry) { builtins::runtime::register_default_host_functions(registry); + #[cfg(all(feature = "http-client", not(target_family = "wasm")))] + { + builtins::runtime::http::register_http_builtin_module_from_catalog( + registry, + &builtins::runtime::http::http_host_catalog(), + ) + .expect("HTTP catalog registration must succeed"); + } } +#[cfg(all( + feature = "runtime", + feature = "http-client", + not(target_family = "wasm") +))] +pub use builtins::runtime::http::{ + HttpConfig, HttpExtension, HttpHostExt, http_host_catalog, register_http_builtin_module, + register_http_builtin_module_from_catalog, +}; #[cfg(feature = "runtime")] pub use builtins::runtime::{ BorrowVmValue, FromVmValue, HostCallResult, IntoHostCallOutcome, TakeVmValue, arg, borrow_arg, @@ -40,7 +61,9 @@ pub use builtins::runtime::{ pub use builtins::runtime::{IoHostExt, IoPolicy}; #[cfg(feature = "runtime")] pub use builtins::runtime::{ - io_host_catalog, sqlite_host_catalog, standard_composition, standard_host_catalog, + io_host_catalog, jit_host_catalog, register_jit_builtin_module, + register_jit_builtin_module_from_catalog, sqlite_host_catalog, standard_composition, + standard_host_catalog, standard_host_catalog_fingerprint, }; pub use builtins::{ BUILTIN_CATALOG, BuiltinFunction, BuiltinNamespaceMemberSpec, BuiltinNamespaceSpec, @@ -52,16 +75,16 @@ pub use builtins::{ pub use bytecode::{ CallableEnvironment, CallableKind, CallablePrototype, CallableTarget, CallableValue, CaptureBindingMode, ExportedCallable, FunctionRegion, HostImport, MAX_FRAME_LOCAL_COUNT, - OpCode, Program, RootCallableBinding, ScriptFunction, TypeMap, Value, ValueType, + OpCode, Program, RootCallableBinding, ScriptFunction, TypeMap, Value, ValueType, VmMap, }; pub use host_api::{ FunctionNameError, HostApiBuilder, HostApiCatalog, HostApiCatalogError, HostApiFingerprint, HostFunctionSchema, HostParamPassing, HostParamSchema, HostSchemaValidationError, - HostTypeSchema, MAX_HOST_CATALOG_FUNCTIONS, MAX_HOST_CATALOG_PARAMETERS, - MAX_HOST_CATALOG_RESOURCES, MAX_HOST_DESCRIPTION_LEN, MAX_HOST_FUNCTION_NAME_LEN, - MAX_HOST_PARAMETER_NAME_LEN, MAX_HOST_RESOURCE_KEY_LEN, MAX_HOST_SCHEMA_DEPTH, - MAX_HOST_SCHEMA_NODES, MAX_HOST_SCHEMA_PROPERTIES, ResourceTypeKey, ResourceTypeKeyError, - ResourceTypeSchema, validate_host_import_schemas, + HostStructField, HostStructSchema, HostTypeSchema, MAX_HOST_CATALOG_FUNCTIONS, + MAX_HOST_CATALOG_PARAMETERS, MAX_HOST_CATALOG_RESOURCES, MAX_HOST_DESCRIPTION_LEN, + MAX_HOST_FUNCTION_NAME_LEN, MAX_HOST_PARAMETER_NAME_LEN, MAX_HOST_RESOURCE_KEY_LEN, + MAX_HOST_SCHEMA_DEPTH, MAX_HOST_SCHEMA_NODES, MAX_HOST_SCHEMA_PROPERTIES, ResourceTypeKey, + ResourceTypeKeyError, ResourceTypeSchema, validate_host_import_schemas, }; #[cfg(feature = "runtime")] pub use vm::runtime::{ @@ -127,9 +150,10 @@ pub use vm::{ InvocationPoll, QueuedScriptInvocation, RegistrySchemaError, ResourceCloseReason, ScriptArgs, ScriptCallback, ScriptResult, StandardSurfaceComposition, StaticHostArgsFunction, StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, - VmYieldReason, async_host, catalog_import_schemas, execution_scope, host_context, - host_extension, operation, register_catalog_function, register_catalog_static_function, - resource, validate_catalog_import_schemas, validate_catalog_import_schemas_with_fingerprints, + VmYieldReason, async_host, catalog_import_schemas, catalog_import_schemas_into, + catalog_named_struct_schemas, execution_scope, host_context, host_extension, operation, + register_catalog_function, register_catalog_static_function, register_host_extension, resource, + validate_catalog_import_schemas, validate_catalog_import_schemas_with_fingerprints, }; #[cfg(feature = "runtime")] pub use vmbc::{ diff --git a/src/vm/async_host/mod.rs b/src/vm/async_host/mod.rs index a135c5fc..bb7bf8c8 100644 --- a/src/vm/async_host/mod.rs +++ b/src/vm/async_host/mod.rs @@ -23,6 +23,15 @@ use std::pin::Pin; use super::*; +mod stream; + +#[allow(unused_imports)] +pub(crate) use stream::{ + HostStreamAction, HostStreamAdmissionError, HostStreamAdmissionRollback, + HostStreamContinuation, HostStreamDriver, HostStreamPoll, HostStreamTermination, + PendingHostStreamTermination, preserve_stream_cleanup, +}; + /// A completion closure that runs against the VM after the async call's /// future has resolved. pub type HostVmCompletion = Box VmResult + Send + 'static>; diff --git a/src/vm/async_host/stream.rs b/src/vm/async_host/stream.rs new file mode 100644 index 00000000..1fc709fb --- /dev/null +++ b/src/vm/async_host/stream.rs @@ -0,0 +1,621 @@ +use std::task::{Context, Poll}; + +use crate::compiler::TypeSchema; +use crate::vm::execution_scope::ExecutionScope; +use crate::vm::operation::OperationCancelReason; +use crate::vm::{CallOutcome, HostOpId, Value, Vm, VmError, VmResult, VmStatus}; + +/// The result of one host-side producer poll for a callable stream. +/// +/// This is a host-only embedding extension point. It does not expose a stream +/// handle or polling operation to scripts. A [`HostStreamDriver::poll_next`] +/// call may yield at most one `Item`; the VM serializes that item with its +/// script callback before polling the producer again. +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum HostStreamPoll { + /// Deliver one producer item to the script callback. + Item(Value), + /// Finish the stream and return the supplied summary to the script call. + Complete(Value), +} + +/// The host driver's response to one completed script callback. +/// +/// Values returned by the callback remain inside the host embedding boundary: +/// no action handle is exposed to scripts. +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) enum HostStreamAction { + /// Continue by returning control to producer polling. + Continue, + /// Cancel the producer after returning the supplied final value. This is + /// distinct from normal completion because the producer may still be + /// blocked publishing the item whose callback requested the stop. + Cancel(Value, OperationCancelReason), +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug)] +pub(crate) enum HostStreamTermination { + Completed, + Cancelled(OperationCancelReason), +} + +pub(crate) struct PendingHostStreamTermination { + pub(crate) driver: Box, + pub(crate) termination: HostStreamTermination, + pub(crate) admission_error: Option, + pub(crate) termination_started: bool, + pub(crate) cleanup_error: Option, +} + +#[allow(dead_code)] +pub(crate) struct HostStreamAdmissionRollback { + pub(crate) driver: Box, + pub(crate) termination: HostStreamTermination, +} + +#[allow(dead_code)] +pub(crate) struct HostStreamAdmissionError { + pub(crate) primary: VmError, + pub(crate) rollback: HostStreamAdmissionRollback, +} + +/// Host-only producer integration for a VM-serialized callable stream. +/// +/// The VM always validates the callback's callable provenance and arity before +/// installing a driver. When its metadata is [`TypeSchema::Callable`], it also +/// validates a map or Named argument and a map, Named, or Object result. HTTP SSE +/// additionally requires the exact `SseCallbackAction` named type or a matching +/// `{ action: string }` object rather than an arbitrary map. Scripts receive +/// ordinary callback items and a final value; they never receive a stream +/// handle or a producer poll API. +/// +/// Implementors must observe these contracts: +/// +/// - [`poll_next`](Self::poll_next) yields at most one item per call and must +/// never re-enter the VM. +/// - [`apply_action`](Self::apply_action) takes ownership of the callback's +/// returned [`Value`], validates it as a driver-specific action, and must not +/// poll the producer. +/// - Dropping the driver is terminal resource cleanup after normal completion, +/// cancellation, or error. Only an early drop represents cancellation, and a +/// `Drop` implementation cannot infer the terminal reason; it must release +/// producer resources without requiring another poll. +#[allow(dead_code)] +pub(crate) trait HostStreamDriver: Send + 'static { + /// Polls the producer for at most one item or its final summary. + fn poll_next(&mut self, cx: &mut Context<'_>) -> Poll>; + + /// Validates and applies one callback-returned action value. + fn apply_action(&mut self, action: Value) -> VmResult; + + /// Acknowledges the item currently owned by the VM callback. Drivers that + /// use a producer-side acknowledgement gate override this hook; generic + /// VM code remains unaware of the transport or adapter implementation. + fn acknowledge_item(&mut self) {} + + /// Completes or cancels adapter-owned scope state after producer + /// quiescence has been established by the driver's operation/resource. + /// The default is suitable for drivers with no scoped child state. + fn terminate( + &mut self, + _scope: &mut ExecutionScope, + _termination: HostStreamTermination, + ) -> VmResult<()> { + Ok(()) + } + + /// Starts stream termination without waiting for an asynchronous producer. + /// + /// The default preserves the legacy one-shot termination contract. Drivers + /// with worker-backed resources override this and retain their state until + /// [`poll_termination`](Self::poll_termination) reports completion. + fn begin_termination( + &mut self, + scope: &mut ExecutionScope, + termination: HostStreamTermination, + ) -> VmResult<()> { + self.terminate(scope, termination) + } + + /// Polls a previously started termination. The default driver has no + /// asynchronous cleanup left after `begin_termination` returns. + fn poll_termination( + &mut self, + _scope: &mut ExecutionScope, + _termination: HostStreamTermination, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } +} + +pub(crate) fn preserve_stream_cleanup(primary: VmError, cleanup: VmResult<()>) -> VmError { + match cleanup { + Ok(()) => primary, + Err(cleanup) => { + use std::fmt::Write as _; + let mut message = primary.to_string(); + let _ = write!(message, "; cleanup failed: {cleanup}"); + VmError::HostError(message) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum HostStreamPhase { + AwaitItem, + RunCallback, +} + +pub(crate) struct HostStreamContinuation { + pub(crate) op_id: HostOpId, + pub(crate) callback: Value, + pub(crate) item: Option, + pub(crate) phase: HostStreamPhase, + pub(crate) parent_stack_base: usize, + pub(crate) parent_frame_count: usize, + pub(crate) parent_ip: usize, +} + +/// HTTP SSE callback results retain the existing named action/object runtime +/// compatibility, while callback inputs use the exact `SseEvent` named schema. +#[cfg(feature = "http-client")] +fn sse_callback_input_schema(params: &[TypeSchema]) -> bool { + matches!( + params, + [TypeSchema::Named(name, args)] if name == "SseEvent" && args.is_empty() + ) +} + +#[cfg(feature = "http-client")] +fn sse_callback_action_result_schema(result: &TypeSchema) -> bool { + match result { + TypeSchema::Named(name, args) => name == "SseCallbackAction" && args.is_empty(), + TypeSchema::Object(fields) => { + fields.len() == 1 + && fields + .get("action") + .is_some_and(|ty| matches!(ty, TypeSchema::String)) + } + _ => false, + } +} + +impl Vm { + /// Installs a host-only callable stream and suspends the current VM call. + /// + /// This Rust embedding API does not create a script-visible handle. The VM + /// always validates that `callback` is a callable owned by this VM and has + /// arity one. When its metadata is [`TypeSchema::Callable`], the VM also + /// validates a map or Named argument and a map, Named, or Object result. HTTP SSE + /// uses [`Self::validate_sse_callback_value`] for the exact + /// `SseCallbackAction` named/object contract rather than an arbitrary map. + /// The VM then owns the callback and driver until completion, cancellation, + /// reset, or error; removing the driver drops it to release producer + /// resources. + /// + /// The driver contract is documented on [`HostStreamDriver`]. In + /// particular, producer polling and callback action application stay + /// serialized and neither driver method may re-enter the VM. + #[allow(dead_code)] + pub(crate) fn submit_callable_stream( + &mut self, + callback: Value, + driver: impl HostStreamDriver, + ) -> Result { + if let Err(error) = self.validate_stream_callback_value(&callback) { + return Err(HostStreamAdmissionError { + primary: error, + rollback: HostStreamAdmissionRollback { + driver: Box::new(driver), + termination: HostStreamTermination::Cancelled(OperationCancelReason::Requested), + }, + }); + } + if self.instance.host_stream.is_some() { + return Err(HostStreamAdmissionError { + primary: VmError::HostError( + "vm already owns an active callable stream".to_string(), + ), + rollback: HostStreamAdmissionRollback { + driver: Box::new(driver), + termination: HostStreamTermination::Cancelled(OperationCancelReason::Requested), + }, + }); + } + let op_id = self.allocate_host_op_id(); + self.host.stream_drivers.insert(op_id, Box::new(driver)); + self.instance.host_stream = Some(HostStreamContinuation { + op_id, + callback, + item: None, + phase: HostStreamPhase::AwaitItem, + parent_stack_base: self.instance.stack.len(), + parent_frame_count: self.instance.execution_frames.len(), + parent_ip: self.instance.ip, + }); + Ok(CallOutcome::Pending(op_id)) + } + + #[allow(dead_code)] + pub(crate) fn rollback_rejected_callable_stream( + &mut self, + rejection: HostStreamAdmissionError, + ) -> VmError { + let primary_message = rejection.primary.to_string(); + self.host + .retain_stream_admission_rollback(rejection.rollback, rejection.primary); + let waker = std::task::Waker::noop(); + let mut cx = Context::from_waker(waker); + match self.host.poll_stream_terminations(&mut cx) { + Poll::Ready(Err(error)) => error, + Poll::Ready(Ok(())) => VmError::HostError(primary_message), + Poll::Pending => VmError::HostError(format!( + "{primary_message}; cleanup pending: callable stream admission rollback" + )), + } + } + + pub fn validate_stream_callback_value(&self, callback: &Value) -> VmResult<()> { + let Value::Callable(callable) = callback else { + return Err(VmError::TypeMismatch("callable")); + }; + if !self.owns_callable(callback) { + return Err(VmError::InvalidCallable); + } + let prototype = self + .program + .callable_prototypes + .get(callable.prototype_id as usize) + .ok_or(VmError::InvalidCallablePrototype(callable.prototype_id))?; + if prototype.arity != 1 { + return Err(VmError::CallableArityMismatch { + prototype_id: callable.prototype_id, + expected: 1, + got: prototype.arity, + }); + } + if let Some(TypeSchema::Callable { params, result }) = &prototype.schema + && (!matches!( + params.as_slice(), + [TypeSchema::Map(_)] | [TypeSchema::Named(_, _)] + ) || !matches!( + result.as_ref(), + TypeSchema::Map(_) | TypeSchema::Named(_, _) | TypeSchema::Object(_) + )) + { + return Err(VmError::TypeMismatch( + "callable stream callback must accept one map or named input and return a map, named value, or object", + )); + } + Ok(()) + } + + #[cfg(feature = "http-client")] + pub fn validate_sse_callback_value(&self, callback: &Value) -> VmResult<()> { + self.validate_stream_callback_value(callback)?; + let Value::Callable(callable) = callback else { + return Ok(()); + }; + let Some(prototype) = self + .program + .callable_prototypes + .get(callable.prototype_id as usize) + else { + return Ok(()); + }; + if let Some(TypeSchema::Callable { params, result, .. }) = &prototype.schema + && (!sse_callback_input_schema(params) || !sse_callback_action_result_schema(result)) + { + return Err(VmError::TypeMismatch("fn(SseEvent) -> SseCallbackAction")); + } + Ok(()) + } + + pub(crate) fn cancel_callable_stream_with_reason( + &mut self, + reason: OperationCancelReason, + ) -> VmResult<()> { + let Some(stream) = self.instance.host_stream.take() else { + return Ok(()); + }; + let cleanup = self + .host + .begin_stream_termination(stream.op_id, HostStreamTermination::Cancelled(reason)) + .and_then(|()| self.poll_stream_termination_once()); + self.instance.waiting_host_op = None; + self.abort_host_invocation(stream.parent_stack_base, stream.parent_frame_count); + if let Some(item) = stream.item { + self.drop_value_with_contract(item); + } + self.drop_value_with_contract(stream.callback); + cleanup + } + + pub(crate) fn terminate_all_callable_streams_with_reason( + &mut self, + reason: OperationCancelReason, + ) -> VmResult<()> { + let mut first_error = None; + if self.instance.host_stream.is_some() { + match self.cancel_callable_stream_with_reason(reason) { + Ok(()) => {} + Err(error) => first_error = Some(error), + } + } + let ids: Vec = self.host.stream_drivers.keys().copied().collect(); + for op_id in ids { + match self + .host + .begin_stream_termination(op_id, HostStreamTermination::Cancelled(reason)) + { + Ok(()) => {} + Err(error) if first_error.is_none() => first_error = Some(error), + Err(_) => {} + } + } + match self.poll_stream_termination_once() { + Ok(()) => {} + Err(error) if first_error.is_none() => first_error = Some(error), + Err(_) => {} + } + match first_error { + Some(error) => Err(error), + None => Ok(()), + } + } + + pub(crate) fn poll_callable_stream( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + if self + .instance + .host_stream + .as_ref() + .map(|stream| stream.phase) + != Some(HostStreamPhase::AwaitItem) + { + return Poll::Ready(Err(VmError::InvalidFrameState( + "callable stream producer polled during callback", + ))); + } + let polled = match self.host.stream_drivers.get_mut(&op_id) { + Some(driver) => driver.poll_next(cx), + None => { + return Poll::Ready(Err(VmError::HostError(format!( + "missing callable stream driver {op_id}" + )))); + } + }; + match polled { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(error)) => { + let cleanup = self.abort_callable_stream(); + Poll::Ready(Err(preserve_stream_cleanup(error, cleanup))) + } + Poll::Ready(Ok(HostStreamPoll::Complete(summary))) => { + match self.finish_callable_stream(summary) { + Ok(true) => Poll::Ready(Ok(())), + Ok(false) => Poll::Pending, + Err(error) => Poll::Ready(Err(error)), + } + } + Poll::Ready(Ok(HostStreamPoll::Item(item))) => { + self.instance.waiting_host_op = None; + if let Some(stream) = self.instance.host_stream.as_mut() { + stream.phase = HostStreamPhase::RunCallback; + stream.item = Some(item); + } + match self.start_callable_stream_callback() { + Ok(VmStatus::Halted) => match self.finish_callable_stream_callback() { + Ok(VmStatus::Halted) => Poll::Ready(Ok(())), + Ok(VmStatus::Waiting(_)) => { + cx.waker().wake_by_ref(); + Poll::Pending + } + Ok(VmStatus::Yielded) => Poll::Ready(Ok(())), + Err(error) => Poll::Ready(Err(error)), + }, + Ok(VmStatus::Yielded | VmStatus::Waiting(_)) => Poll::Ready(Ok(())), + Err(error) => { + let cleanup = self.abort_callable_stream(); + Poll::Ready(Err(preserve_stream_cleanup(error, cleanup))) + } + } + } + } + } + + fn start_callable_stream_callback(&mut self) -> VmResult { + let (callback, item) = { + let stream = self + .instance + .host_stream + .as_mut() + .ok_or(VmError::InvalidFrameState( + "missing callable stream continuation", + ))?; + ( + stream.callback.clone(), + stream + .item + .take() + .ok_or(VmError::InvalidFrameState("missing callable stream item"))?, + ) + }; + let operand_stack_base = self.instance.stack.len(); + let Value::Callable(callable) = callback else { + return Err(VmError::InvalidCallable); + }; + let outcome = self.enter_script_frame( + callable.prototype_id, + Some(callable), + vec![item], + operand_stack_base, + None, + crate::vm::instance::FrameContinuation::ReturnToHost, + )?; + match outcome { + crate::vm::ExecOutcome::Continue => self.run_internal(None, false), + crate::vm::ExecOutcome::Halted => Ok(VmStatus::Halted), + crate::vm::ExecOutcome::Yielded => Ok(VmStatus::Yielded), + crate::vm::ExecOutcome::Waiting(id) => Ok(VmStatus::Waiting(id)), + } + } + + pub(crate) fn resume_callable_stream_after_run( + &mut self, + status: VmStatus, + ) -> VmResult { + if self + .instance + .host_stream + .as_ref() + .is_none_or(|stream| stream.phase != HostStreamPhase::RunCallback) + || status != VmStatus::Halted + { + return Ok(status); + } + self.finish_callable_stream_callback() + } + + pub(crate) fn abort_callable_stream_on_run_error(&mut self) -> VmResult<()> { + if self + .instance + .host_stream + .as_ref() + .is_some_and(|stream| stream.phase == HostStreamPhase::RunCallback) + { + self.abort_callable_stream() + } else { + Ok(()) + } + } + + fn finish_callable_stream_callback(&mut self) -> VmResult { + let Some(action) = self.instance.host_return.take() else { + let error = VmError::InvalidFrameState("callable stream callback returned no action"); + return Err(preserve_stream_cleanup(error, self.abort_callable_stream())); + }; + let op_id = self + .instance + .host_stream + .as_ref() + .ok_or(VmError::InvalidFrameState( + "missing callable stream continuation", + ))? + .op_id; + if let Some(stream) = self.instance.host_stream.as_ref() { + self.instance.ip = stream.parent_ip; + } + let applied = self + .host + .stream_drivers + .get_mut(&op_id) + .ok_or_else(|| VmError::HostError(format!("missing callable stream driver {op_id}")))? + .apply_action(action); + match applied { + Ok(HostStreamAction::Continue) => { + if let Some(driver) = self.host.stream_drivers.get_mut(&op_id) { + driver.acknowledge_item(); + } + if let Some(stream) = self.instance.host_stream.as_mut() { + stream.phase = HostStreamPhase::AwaitItem; + } + self.instance.waiting_host_op = Some(crate::vm::host::WaitingHostOp { + op_id, + source: crate::vm::host::WaitingHostOpSource::CallableStream, + expected_return_type: None, + expected_return_schema: None, + }); + Ok(VmStatus::Waiting(op_id)) + } + Ok(HostStreamAction::Cancel(summary, reason)) => { + match self.finish_callable_stream_with_termination( + summary, + HostStreamTermination::Cancelled(reason), + ) { + Ok(true) => Ok(VmStatus::Halted), + Ok(false) => Ok(VmStatus::Waiting(op_id)), + Err(error) => Err(error), + } + } + Err(error) => Err(preserve_stream_cleanup(error, self.abort_callable_stream())), + } + } + + fn finish_callable_stream(&mut self, summary: Value) -> VmResult { + self.finish_callable_stream_with_termination(summary, HostStreamTermination::Completed) + } + + fn finish_callable_stream_with_termination( + &mut self, + summary: Value, + termination: HostStreamTermination, + ) -> VmResult { + let Some(stream) = self.instance.host_stream.take() else { + return Err(VmError::InvalidFrameState( + "missing callable stream continuation", + )); + }; + let cleanup = self + .host + .begin_stream_termination(stream.op_id, termination) + .and_then(|()| self.poll_stream_termination_once()); + self.instance.waiting_host_op = None; + self.drop_value_with_contract(stream.callback); + if let Some(item) = stream.item { + self.drop_value_with_contract(item); + } + if let Err(error) = cleanup { + self.abort_host_invocation(stream.parent_stack_base, stream.parent_frame_count); + return Err(error); + } + self.instance.stack.push(summary); + if self.host.has_pending_stream_terminations() { + self.instance.waiting_host_op = Some(crate::vm::host::WaitingHostOp { + op_id: stream.op_id, + source: crate::vm::host::WaitingHostOpSource::CallableStreamTermination, + expected_return_type: None, + expected_return_schema: None, + }); + Ok(false) + } else { + Ok(true) + } + } + + fn poll_stream_termination_once(&mut self) -> VmResult<()> { + let waker = std::task::Waker::noop(); + let mut cx = Context::from_waker(waker); + match self.host.poll_stream_terminations(&mut cx) { + Poll::Pending | Poll::Ready(Ok(())) => Ok(()), + Poll::Ready(Err(error)) => Err(error), + } + } + + fn abort_callable_stream(&mut self) -> VmResult<()> { + let Some(stream) = self.instance.host_stream.take() else { + return Ok(()); + }; + let cleanup = self + .host + .begin_stream_termination( + stream.op_id, + HostStreamTermination::Cancelled(OperationCancelReason::Requested), + ) + .and_then(|()| self.poll_stream_termination_once()); + self.instance.waiting_host_op = None; + self.abort_host_invocation(stream.parent_stack_base, stream.parent_frame_count); + self.drop_value_with_contract(stream.callback); + if let Some(item) = stream.item { + self.drop_value_with_contract(item); + } + cleanup + } +} diff --git a/src/vm/execution_scope.rs b/src/vm/execution_scope.rs index aee9c26d..edd5340c 100644 --- a/src/vm/execution_scope.rs +++ b/src/vm/execution_scope.rs @@ -423,6 +423,19 @@ impl ExecutionScope { } } + /// Polls a previously terminal operation through its quiescence boundary + /// without driving it a second time. + pub fn poll_operation_quiescence( + &mut self, + id: OperationId, + cx: &mut Context<'_>, + ) -> Poll> { + match self.operations.poll_quiescent(id, cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(result) => Poll::Ready(result.map_err(ExecutionScopeError::Operation)), + } + } + /// Aborts a started operation in one step so it never produces a /// guest-visible result: cancels the driver exactly once if pending /// (recording the first reason), waits through the driver's @@ -467,6 +480,18 @@ impl ExecutionScope { .map_err(ExecutionScopeError::Resource) } + /// Polls a resource that has already entered the closing state. + #[allow(dead_code)] + pub(crate) fn poll_resource_close( + &mut self, + handle: ResourceHandle, + cx: &mut Context<'_>, + ) -> Poll> { + self.resources + .poll_close(Resource::::from_handle(handle), cx) + .map_err(ExecutionScopeError::Resource) + } + /// The first cleanup failure recorded so far, if any. pub fn first_error(&self) -> Option<&ScopeCloseError> { self.first_error.as_ref() @@ -509,16 +534,6 @@ impl ExecutionScope { } } - pub(crate) fn cancel_operations_and_wait( - &mut self, - reason: OperationCancelReason, - ) -> OperationCancelSummary { - let summary = self.operations.cancel_all_and_wait(reason); - self.record_operation_summary(&summary); - self.operations_drained = true; - summary - } - /// Runs the VM-Drop-only nonblocking resource close launch after the normal /// scope close poll has cancelled operations and begun all current leaves. /// This never changes the scope state or claims quiescence. diff --git a/src/vm/host.rs b/src/vm/host.rs index 20643dbc..266b1a06 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; use std::task::{Context, Poll, Wake, Waker}; @@ -8,7 +9,7 @@ use crate::vm::operation::{OperationCancelReason, OperationId, OperationOutcome} use crate::vm::resource::handle::ResourceHandle; use crate::vm::resource::table::ResourceTable; -use super::async_host::{HostFuture, HostFutureOutput}; +use super::async_host::{HostFuture, HostFutureOutput, preserve_stream_cleanup}; use super::capability::CapabilityProfile; use super::*; @@ -361,6 +362,9 @@ pub struct HostFunctionRegistry { /// This is explicit per-instance state: the outer standard-runtime /// constructor installs it; `src/vm` never names a concrete domain. standard_composition: Option>, + /// Catalog named-struct bodies. Compiler identity stays `TypeSchema::Named`; + /// this table supplies Object bodies for nested-resource classification. + named_struct_schemas: Arc>, } impl Default for HostFunctionRegistry { @@ -384,6 +388,7 @@ impl HostFunctionRegistry { registry_generation_token: Arc::new(()), registry_generation: Arc::new(AtomicU64::new(0)), standard_composition: None, + named_struct_schemas: Arc::new(HashMap::new()), } } @@ -428,6 +433,43 @@ impl HostFunctionRegistry { self.invalidate_plan_cache(); } + /// Merges catalog named-struct bodies used to classify nested resources + /// inside `TypeSchema::Named` values. Compiler identity stays named; + /// runtime values remain maps. + /// + /// Identical duplicate bodies are accepted. Conflicting bodies for the + /// same name are rejected without mutating the registry, so callers that + /// compose HTTP/SQLite/JIT after defaults cannot silently discard earlier + /// schemas. + pub fn install_named_struct_schemas( + &mut self, + schemas: HashMap, + ) -> VmResult<()> { + if schemas.is_empty() { + return Ok(()); + } + for (name, schema) in &schemas { + if let Some(existing) = self.named_struct_schemas.get(name) + && existing != schema + { + return Err(VmError::HostError(format!( + "conflicting named struct schema '{name}'" + ))); + } + } + let map = Arc::make_mut(&mut self.named_struct_schemas); + for (name, schema) in schemas { + map.entry(name).or_insert(schema); + } + self.invalidate_plan_cache(); + Ok(()) + } + + /// Catalog named-struct object bodies installed for VM resource walks. + pub fn named_struct_schemas(&self) -> &HashMap { + &self.named_struct_schemas + } + /// The installed standard-surface composition strategy, if any. pub fn standard_composition( &self, @@ -725,6 +767,56 @@ impl HostFunctionRegistry { self.register_catalog_entry(schema, RegistryEntryKind::Static(function)) } + /// Applies a registry extension to a private snapshot and publishes it + /// only after every registration succeeds. Extensions use this to keep + /// catalog and dispatch state atomic when a later schema is invalid. + pub fn transactionally(&mut self, register: F) -> VmResult + where + F: FnOnce(&mut Self) -> VmResult, + { + let mut staged = self.clone(); + let result = register(&mut staged)?; + *self = staged; + Ok(result) + } + + /// Registers one exact catalog entry while checking the source-level + /// function identity supplied by an extension. + pub fn register_exact_static( + &mut self, + name: &str, + arity: u8, + schema: HostImportSchema, + function: StaticHostFunction, + ) -> VmResult { + if schema.name != name || schema.arity() != usize::from(arity) { + return Err(VmError::HostError(format!( + "host schema for '{name}' does not match its exact adapter identity" + ))); + } + self.register_catalog_static(schema, function) + .map_err(|error| VmError::HostError(error.to_string())) + } + + /// Grants a registered extension import its host capability without + /// coupling the VM to the extension's concrete domain. + pub fn authorize_registered_builtin_import(&mut self, name: &str) { + self.capability_profile = Arc::new(self.capability_profile.with_host_import(name)); + self.invalidate_plan_cache(); + } + + /// Marks an exact import as owning its pending operation. Pending + /// dispatch is resolved from the generic VM operation/stream registries; + /// the marker is intentionally a registration hook with no domain state. + pub fn mark_exact_runtime_owned_pending(&mut self, name: &str) -> VmResult<()> { + if !self.contains_name(name) { + return Err(VmError::HostError(format!( + "cannot mark unregistered host import '{name}' as runtime-owned" + ))); + } + Ok(()) + } + pub fn register_catalog_stack( &mut self, schema: HostImportSchema, @@ -1085,6 +1177,7 @@ impl HostFunctionRegistry { } } vm.set_default_host_fallback_enabled(false); + vm.host.named_struct_schemas = Arc::clone(&self.named_struct_schemas); vm.host.allowed_builtin_calls = plan.allowed_builtin_calls.clone(); vm.host.allow_default_builtin_capabilities = plan.allow_default_builtin_capabilities; vm.host.allowed_host_function_slots = plan.allowed_host_function_slots.clone(); @@ -1268,10 +1361,35 @@ fn callable_schema_matches( && callable_schema_matches(expected_result, actual_result) } (HostTypeSchema::Resource(expected), TypeSchema::Resource(actual)) => expected == actual, + ( + HostTypeSchema::Named { + name: expected_name, + .. + }, + TypeSchema::Named(actual_name, args), + ) => expected_name == actual_name && args.is_empty(), + (HostTypeSchema::Named { fields, .. }, TypeSchema::Object(actual_fields)) => { + named_fields_match_object(fields, actual_fields) + } _ => false, } } +fn named_fields_match_object( + fields: &[crate::host_api::HostStructField], + actual_fields: &HashMap, +) -> bool { + fields + .iter() + .all(|field| match actual_fields.get(&field.name) { + Some(actual) => callable_schema_matches(&field.ty, actual), + None => matches!(field.ty, HostTypeSchema::Optional(_)), + }) + && actual_fields + .keys() + .all(|name| fields.iter().any(|field| field.name == *name)) +} + fn host_callable_schema_matches(expected: &HostTypeSchema, actual: &HostTypeSchema) -> bool { match (expected, actual) { (HostTypeSchema::Unknown, _) => true, @@ -1309,6 +1427,30 @@ fn host_callable_schema_matches(expected: &HostTypeSchema, actual: &HostTypeSche (HostTypeSchema::Resource(expected), HostTypeSchema::Resource(actual)) => { expected == actual } + ( + HostTypeSchema::Named { + name: expected_name, + fields: expected_fields, + }, + HostTypeSchema::Named { + name: actual_name, + fields: actual_fields, + }, + ) => { + expected_name == actual_name + && expected_fields.iter().all(|expected| { + actual_fields.iter().any(|actual| { + actual.name == expected.name + && host_callable_schema_matches(&expected.ty, &actual.ty) + }) + }) + && actual_fields.iter().all(|actual| { + expected_fields + .iter() + .any(|expected| expected.name == actual.name) + || matches!(actual.ty, HostTypeSchema::Optional(_)) + }) + } _ => false, } } @@ -1466,6 +1608,21 @@ fn validate_host_value( .validate_resource_type_key(handle, key) .map_err(|error| VmError::HostError(error.to_string())) } + HostTypeSchema::Named { fields, .. } => { + let Value::Map(values) = value else { + return Err(VmError::TypeMismatch("map")); + }; + for field in fields { + match values.get(&Value::string(&field.name)) { + Some(field_value) => { + validate_host_value(field_value, &field.ty, program, resources)?; + } + None if matches!(field.ty, HostTypeSchema::Optional(_)) => {} + None => return Err(VmError::TypeMismatch("map")), + } + } + Ok(()) + } } } @@ -1482,6 +1639,8 @@ pub(super) enum WaitingHostOpSource { HostBridge, Manual, ScopedOperation, + CallableStream, + CallableStreamTermination, } struct NoopWake; @@ -1973,11 +2132,19 @@ impl Vm { VmError::ExecutionScope(ExecutionScopeError::Operation(error)) })?; self.host.scoped_operation_completions.remove(&op_id); - self.execution_scope() - .abort_operation(op_id, reason) - .map(|_| ()) - .map_err(VmError::ExecutionScope) + let scope = self.execution_scope(); + scope + .cancel_operation(op_id, reason) + .map_err(VmError::ExecutionScope)?; + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + match scope.poll_operation_quiescence(op_id, &mut cx) { + Poll::Pending | Poll::Ready(Ok(_)) => Ok(()), + Poll::Ready(Err(error)) => Err(VmError::ExecutionScope(error)), + } } + WaitingHostOpSource::CallableStream => self.cancel_callable_stream_with_reason(reason), + WaitingHostOpSource::CallableStreamTermination => Ok(()), } } @@ -1990,7 +2157,12 @@ impl Vm { }; match waiting.source { WaitingHostOpSource::HostBridge => { - self.host.request_cancel_host_op(waiting.op_id, reason) + let bridge_cleanup = self.host.request_cancel_host_op(waiting.op_id, reason); + let stream_cleanup = self.cancel_callable_stream_with_reason(reason); + match bridge_cleanup { + Ok(()) => stream_cleanup, + Err(error) => Err(preserve_stream_cleanup(error, stream_cleanup)), + } } WaitingHostOpSource::Manual => { self.instance.waiting_host_op = None; @@ -2000,6 +2172,14 @@ impl Vm { self.instance.waiting_host_op = None; self.cleanup_waiting_host_op(waiting, reason) } + WaitingHostOpSource::CallableStream => { + self.instance.waiting_host_op = None; + self.cancel_callable_stream_with_reason(reason) + } + WaitingHostOpSource::CallableStreamTermination => { + self.instance.waiting_host_op = None; + Ok(()) + } } } @@ -2047,6 +2227,10 @@ impl Vm { WaitingHostOpSource::ScopedOperation => { self.cleanup_waiting_host_op(waiting, OperationCancelReason::Requested) } + WaitingHostOpSource::CallableStream => Ok(()), + WaitingHostOpSource::CallableStreamTermination => Err(VmError::HostError( + "callable stream termination cannot be completed as a host operation".to_string(), + )), }; cleanup_result?; self.instance.waiting_host_op = None; @@ -2059,7 +2243,10 @@ impl Vm { pub fn poll_waiting_host_op(&mut self, cx: &mut Context<'_>) -> Poll> { let Some(waiting) = self.instance.waiting_host_op.clone() else { - return Poll::Ready(Ok(())); + return match self.host.poll_stream_terminations(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(result) => Poll::Ready(result), + }; }; if matches!(waiting.source, WaitingHostOpSource::HostBridge) @@ -2080,6 +2267,25 @@ impl Vm { let bridge_owned = matches!(waiting.source, WaitingHostOpSource::HostBridge) && self.host.is_bridge_operation_tracked(waiting.op_id); + if matches!(waiting.source, WaitingHostOpSource::CallableStream) { + return self.poll_callable_stream(waiting.op_id, cx); + } + if matches!( + waiting.source, + WaitingHostOpSource::CallableStreamTermination + ) { + return match self.host.poll_stream_terminations(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(())) => { + self.instance.waiting_host_op = None; + Poll::Ready(Ok(())) + } + Poll::Ready(Err(error)) => { + self.instance.waiting_host_op = None; + Poll::Ready(Err(error)) + } + }; + } let submitted = self.host.submitted_host_ops.contains(&waiting.op_id); let poll_result: Poll> = match waiting.source { WaitingHostOpSource::HostBridge => { @@ -2113,6 +2319,10 @@ impl Vm { )))); } WaitingHostOpSource::ScopedOperation => self.poll_scoped_operation(waiting.op_id, cx), + WaitingHostOpSource::CallableStream => unreachable!("callable stream handled above"), + WaitingHostOpSource::CallableStreamTermination => { + unreachable!("callable stream termination handled above") + } }; match poll_result { @@ -2914,7 +3124,7 @@ impl Vm { let resume_ip = self.call_resume_ip(call_ip)?; self.set_waiting_host_op_with_return( op_id, - self.host_call_pending_source(), + self.host_call_pending_source(op_id), expected_return_type, expected_return_schema, )?; @@ -3059,7 +3269,7 @@ impl Vm { let resume_ip = self.call_resume_ip(call_ip)?; self.set_waiting_host_op_with_return( op_id, - self.host_call_pending_source(), + self.host_call_pending_source(op_id), expected_return_type, expected_return_schema, )?; @@ -3134,7 +3344,7 @@ impl Vm { let resume_ip = self.call_resume_ip(call_ip)?; self.set_waiting_host_op_with_return( op_id, - self.host_call_pending_source(), + self.host_call_pending_source(op_id), expected_return_type, expected_return_schema, )?; @@ -3164,7 +3374,20 @@ impl Vm { Ok(resume_ip) } - fn host_call_pending_source(&self) -> WaitingHostOpSource { + fn host_call_pending_source(&self, op_id: HostOpId) -> WaitingHostOpSource { + if self.host.stream_drivers.contains_key(&op_id) { + return WaitingHostOpSource::CallableStream; + } + if let Ok(operation_id) = OperationId::from_raw(op_id) + && self + .host + .execution_scope + .operations() + .status(operation_id) + .is_ok() + { + return WaitingHostOpSource::ScopedOperation; + } if self.host.async_bridge.is_some() { WaitingHostOpSource::HostBridge } else { @@ -3359,12 +3582,16 @@ impl Vm { mod tests { use std::collections::HashMap; - use super::callable_schema_matches; + use super::{ + VmError, callable_schema_matches, host_callable_schema_matches, validate_host_value, + }; use crate::ResourceTypeKey; use crate::compiler::TypeSchema; use crate::host_api::{ - HostApiCatalog, HostFunctionSchema, HostImportSchema, HostTypeSchema, MAX_HOST_SCHEMA_DEPTH, + HostApiCatalog, HostFunctionSchema, HostImportSchema, HostStructField, HostTypeSchema, + MAX_HOST_SCHEMA_DEPTH, }; + use crate::{OpCode, Program, Value}; fn key(name: &str) -> ResourceTypeKey { ResourceTypeKey::new(name).expect("test resource key") @@ -3471,6 +3698,142 @@ mod tests { )); } + fn named_event_fields() -> Vec { + vec![ + HostStructField::new("id", HostTypeSchema::Int), + HostStructField::new( + "note", + HostTypeSchema::Optional(Box::new(HostTypeSchema::String)), + ), + ] + } + + #[test] + fn callable_schema_matches_named_structs_by_name_and_rejects_maps() { + let expected = HostTypeSchema::named_struct("SseEvent", named_event_fields()); + assert!(callable_schema_matches( + &expected, + &TypeSchema::Named("SseEvent".to_string(), Vec::new()), + )); + assert!(!callable_schema_matches( + &expected, + &TypeSchema::Named("OtherEvent".to_string(), Vec::new()), + )); + assert!(!callable_schema_matches( + &expected, + &TypeSchema::Map(Box::new(TypeSchema::Unknown)), + )); + + let mut object_fields = HashMap::new(); + object_fields.insert("id".to_string(), TypeSchema::Int); + object_fields.insert( + "note".to_string(), + TypeSchema::Optional(Box::new(TypeSchema::String)), + ); + assert!(callable_schema_matches( + &expected, + &TypeSchema::Object(object_fields), + )); + assert!(!callable_schema_matches( + &expected, + &TypeSchema::Named("SseEvent".to_string(), vec![TypeSchema::Int]), + )); + } + + #[test] + fn host_callable_schema_matches_named_structs_recursively() { + let expected = HostTypeSchema::named_struct("SseEvent", named_event_fields()); + let matching = HostTypeSchema::named_struct("SseEvent", named_event_fields()); + let mismatched = HostTypeSchema::named_struct( + "SseEvent", + vec![HostStructField::new("id", HostTypeSchema::String)], + ); + assert!(host_callable_schema_matches(&expected, &matching)); + assert!(!host_callable_schema_matches(&expected, &mismatched)); + assert!(!host_callable_schema_matches( + &expected, + &HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)), + )); + } + + #[test] + fn named_host_return_allows_omitted_optional_fields() { + let program = Program::new(Vec::new(), vec![OpCode::Ret as u8]); + let resources = crate::vm::resource::ResourceTable::new().expect("resource table"); + let schema = HostTypeSchema::named_struct("OptBox", named_event_fields()); + let mut values = crate::bytecode::VmMap::new(); + values.insert(Value::string("id"), Value::Int(1)); + validate_host_value(&Value::Map(values.into()), &schema, &program, &resources) + .expect("optional named field may be omitted on host return"); + } + + #[test] + fn named_host_return_still_requires_non_optional_fields() { + let program = Program::new(Vec::new(), vec![OpCode::Ret as u8]); + let resources = crate::vm::resource::ResourceTable::new().expect("resource table"); + let schema = HostTypeSchema::named_struct("OptBox", named_event_fields()); + let values = crate::bytecode::VmMap::new(); + let error = validate_host_value(&Value::Map(values.into()), &schema, &program, &resources) + .expect_err("required named field must stay present"); + assert!(matches!(error, VmError::TypeMismatch("map"))); + } + + #[test] + fn install_named_struct_schemas_merges_identical_and_rejects_conflicts() { + let mut first = HashMap::new(); + first.insert("HandleBox".to_string(), TypeSchema::Int); + let mut second = HashMap::new(); + second.insert("OtherBox".to_string(), TypeSchema::String); + let mut conflict = HashMap::new(); + conflict.insert("HandleBox".to_string(), TypeSchema::String); + + let mut registry = super::HostFunctionRegistry::empty(); + registry + .install_named_struct_schemas(first.clone()) + .expect("first install"); + registry + .install_named_struct_schemas(first) + .expect("identical duplicate must be accepted"); + registry + .install_named_struct_schemas(second) + .expect("disjoint merge must keep both schemas"); + assert!(registry.named_struct_schemas().contains_key("HandleBox")); + assert!(registry.named_struct_schemas().contains_key("OtherBox")); + let error = registry + .install_named_struct_schemas(conflict) + .expect_err("conflicting schema must be rejected"); + assert!( + matches!(error, VmError::HostError(ref message) if message.contains("conflicting named struct schema")), + "unexpected error: {error:?}" + ); + assert!( + matches!( + registry.named_struct_schemas().get("HandleBox"), + Some(TypeSchema::Int) + ), + "conflict must not mutate the installed table" + ); + } + + #[test] + fn bind_vm_copies_named_struct_schemas_onto_host_runtime() { + let mut schemas = HashMap::new(); + schemas.insert("HandleBox".to_string(), TypeSchema::Int); + let mut registry = super::HostFunctionRegistry::empty(); + registry + .install_named_struct_schemas(schemas) + .expect("install"); + let mut vm = crate::Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + registry.bind_vm_cached(&mut vm).expect("bind"); + assert!( + matches!( + vm.host.named_struct_schemas.get("HandleBox"), + Some(TypeSchema::Int) + ), + "bind must copy named-struct bodies onto the VM host runtime" + ); + } + #[test] fn catalog_registration_rejects_overdepth_schema_before_mutation() { let valid_function = diff --git a/src/vm/host_extension.rs b/src/vm/host_extension.rs index d350768e..32ad50f9 100644 --- a/src/vm/host_extension.rs +++ b/src/vm/host_extension.rs @@ -14,11 +14,13 @@ //! [`HostFunctionRegistry`]. Registration is validated against the //! extension's [`HostApiCatalog`] via [`catalog_import_schemas`] so the //! registered function declarations — parameter labels, type schemas and -//! passing modes — match the catalog exactly. The catalog is the -//! authoritative host-side contract: it carries the fingerprint and the -//! resource type keys the host exposes, and the same catalog can be -//! supplied to the compiler so the program's `HostImport`s resolve against -//! it. +//! passing modes — match the catalog exactly. Named-struct bodies from +//! [`HostExtension::catalog`] are installed atomically by +//! [`register_host_extension`] / [`super::Vm::install_extension`] before +//! `register`. The catalog is the authoritative host-side contract: it +//! carries the fingerprint and the resource type keys the host exposes, and +//! the same catalog can be supplied to the compiler so the program's +//! `HostImport`s resolve against it. //! //! `src/vm` therefore stays host-agnostic: resource classes, pending //! operations and module state are supplied by the extension, while the @@ -53,15 +55,26 @@ pub use crate::host_api::{HostImportParam, HostImportSchema}; /// Used directly by embedders; the `register` / `install` lifecycle is split /// so an extension can also be registered into a caller-supplied (e.g. /// restricted / capability-granted) [`HostFunctionRegistry`] by calling -/// [`HostExtension::register`] directly and binding it with +/// [`register_host_extension`] and binding it with /// [`HostFunctionRegistry::bind_vm_cached`]. pub trait HostExtension: Send + Sync + 'static { + /// Catalog whose named-struct bodies are installed atomically before + /// [`Self::register`] by [`super::Vm::install_extension`] and + /// [`register_host_extension`]. Compiler import identity stays + /// `TypeSchema::Named`. The default is none. + fn catalog(&self) -> Option<&HostApiCatalog> { + None + } + /// Registers this extension's host functions into `registry`. /// /// Registration must be validated against the extension's - /// [`HostApiCatalog`] (e.g. [`catalog_import_schemas`] plus the + /// [`HostApiCatalog`] (e.g. [`catalog_import_schemas_into`] or + /// [`catalog_import_schemas`] plus the /// [`validate_catalog_import_schemas`] family); a name-only fallback is - /// not part of this surface. The default registers nothing. + /// not part of this surface. Prefer [`register_host_extension`] or + /// [`super::Vm::install_extension`] so catalog named-struct bodies are + /// installed before registration. The default registers nothing. fn register(&self, registry: &mut super::host::HostFunctionRegistry) -> VmResult<()> { let _ = registry; Ok(()) @@ -96,6 +109,9 @@ pub trait HostExtension: Send + Sync + 'static { /// so a failure leaves the VM unmodified. fn install_into(&self, vm: &mut super::Vm) -> VmResult<()> { let mut registry = super::host::HostFunctionRegistry::new(); + if let Some(catalog) = self.catalog() { + registry.install_named_struct_schemas(catalog_named_struct_schemas(catalog))?; + } self.register(&mut registry)?; registry.bind_vm_cached(vm)?; self.install(vm); @@ -118,6 +134,46 @@ pub fn catalog_import_schemas(catalog: &HostApiCatalog, name: &str) -> Vec std::collections::HashMap { + catalog + .structs() + .iter() + .map(|schema| (schema.name.clone(), schema.to_compiler_object_schema())) + .collect() +} + +/// Installs catalog named-struct bodies onto `registry`, then returns exact +/// import schemas for `name`. Use this from [`HostExtension::register`] so +/// resource walks see nested `TypeSchema::Named` bodies without a separate +/// test-only table install. Compiler import identity stays `TypeSchema::Named`. +pub fn catalog_import_schemas_into( + registry: &mut super::host::HostFunctionRegistry, + catalog: &HostApiCatalog, + name: &str, +) -> VmResult> { + registry.install_named_struct_schemas(catalog_named_struct_schemas(catalog))?; + Ok(catalog_import_schemas(catalog, name)) +} + +/// Registers `extension` after atomically installing named-struct bodies from +/// [`HostExtension::catalog`]. Restricted-registry callers should use this +/// instead of calling [`HostExtension::register`] directly. +pub fn register_host_extension( + registry: &mut super::host::HostFunctionRegistry, + extension: &dyn HostExtension, +) -> VmResult<()> { + if let Some(catalog) = extension.catalog() { + registry.install_named_struct_schemas(catalog_named_struct_schemas(catalog))?; + } + extension.register(registry) +} + fn catalog_import_schemas_with_fingerprint( catalog: &HostApiCatalog, name: &str, @@ -219,8 +275,8 @@ pub enum CatalogRegistrationError { ParameterTypeMismatch { name: String, index: usize, - expected: HostTypeSchema, - actual: HostTypeSchema, + expected: Box, + actual: Box, }, /// A parameter's passing mode differs from the catalog declaration. ParameterPassingMismatch { @@ -239,8 +295,8 @@ pub enum CatalogRegistrationError { /// The selected declaration has a different return schema. ReturnTypeMismatch { name: String, - expected: HostTypeSchema, - actual: HostTypeSchema, + expected: Box, + actual: Box, }, /// More than one catalog overload matches an arity-only selection. AmbiguousOverload { @@ -425,8 +481,8 @@ fn schema_field_mismatch( return Some(CatalogRegistrationError::ParameterTypeMismatch { name: name.to_string(), index, - expected: expected.schema.clone(), - actual: actual.schema.clone(), + expected: Box::new(expected.schema.clone()), + actual: Box::new(actual.schema.clone()), }); } if expected.passing != actual.passing { @@ -449,8 +505,8 @@ fn schema_field_mismatch( if candidate.return_type != selected.return_type { return Some(CatalogRegistrationError::ReturnTypeMismatch { name: name.to_string(), - expected: candidate.return_type.clone(), - actual: selected.return_type.clone(), + expected: Box::new(candidate.return_type.clone()), + actual: Box::new(selected.return_type.clone()), }); } None diff --git a/src/vm/host_runtime.rs b/src/vm/host_runtime.rs index c51759a5..dfdd2777 100644 --- a/src/vm/host_runtime.rs +++ b/src/vm/host_runtime.rs @@ -22,6 +22,10 @@ use std::sync::Arc; use std::task::{Context, Poll}; use crate::host_api::HostImportSchema; +use crate::vm::async_host::{ + HostStreamAdmissionRollback, HostStreamDriver, HostStreamTermination, + PendingHostStreamTermination, preserve_stream_cleanup, +}; use crate::vm::execution_scope::{ExecutionScope, ExecutionScopeError, ScopeCloseOutcome}; use crate::vm::host::{ HostAsyncBridge, HostAsyncOpTerminal, HostOpId, ScopedOperationCompletion, VmHostFunction, @@ -79,6 +83,9 @@ pub(crate) struct HostRuntime { /// reset must stay non-reusable and must not silently start another reset /// or publish a callback registry on a later poll. scope_reset_error: Option, + /// An early reset failure that is outside the generic scope error domain. + /// This remains authoritative so an empty active scope cannot look reusable. + reset_error: Option, /// The one replacement scope allocated for the current reset. It remains /// unpublished until the old scope in `execution_scope` reaches /// quiescence. @@ -121,6 +128,16 @@ pub(crate) struct HostRuntime { bridge_operations: HashMap, /// Adapter-owned completions for operations driven by the execution scope. pub(crate) scoped_operation_completions: HashMap, + /// Catalog named-struct bodies copied from the bound host registry. + /// Compiler identity stays `TypeSchema::Named`; this table supplies Object + /// bodies for runtime validation and nested-resource classification. + pub(crate) named_struct_schemas: Arc>, + /// Host-owned callable stream drivers. The VM stores only this generic + /// driver contract; HTTP/SSE state remains in the adapter module. + pub(crate) stream_drivers: HashMap>, + /// Drivers whose callable continuation has ended but whose worker/resource + /// cleanup still needs asynchronous polling. + pub(crate) pending_stream_terminations: HashMap, } impl HostRuntime { @@ -148,6 +165,7 @@ impl HostRuntime { .expect("host runtime execution-scope identity space must be available"), scope_reset_pending: false, scope_reset_error: None, + reset_error: None, replacement_execution_scope: None, module_state_store: super::host_state::ModuleStateStore::new(), allow_default_builtin_capabilities: true, @@ -156,9 +174,12 @@ impl HostRuntime { allowed_host_function_slots: Vec::new(), allow_default_host_fallback: true, standard_composition: None, + named_struct_schemas: Arc::new(HashMap::new()), submitted_host_ops: HashSet::new(), bridge_operations: HashMap::new(), scoped_operation_completions: HashMap::new(), + stream_drivers: HashMap::new(), + pending_stream_terminations: HashMap::new(), } } @@ -461,9 +482,17 @@ impl HostRuntime { if let Some(error) = self.scope_reset_error.clone() { return Err(VmError::ExecutionScope(error)); } + if let Some(error) = self.reset_error.clone() { + return Err(VmError::HostError(error)); + } if !self.scope_reset_pending { - self.request_cancel_submitted_host_ops(OperationCancelReason::VmReset)?; + if let Err(error) = + self.request_cancel_submitted_host_ops(OperationCancelReason::VmReset) + { + self.mark_reset_failed(&error); + return Err(error); + } // Allocate the replacement before publishing or closing anything. // There is no second allocation after the old scope quiesces. let replacement = match ExecutionScope::new() { @@ -482,12 +511,15 @@ impl HostRuntime { return Err(VmError::ExecutionScope(error)); } self.scoped_operation_completions.clear(); - self.execution_scope - .cancel_operations_and_wait(crate::vm::operation::OperationCancelReason::VmReset); self.replacement_execution_scope = Some(replacement); self.scope_reset_pending = true; } else { - self.request_cancel_submitted_host_ops(OperationCancelReason::VmReset)?; + if let Err(error) = + self.request_cancel_submitted_host_ops(OperationCancelReason::VmReset) + { + self.mark_reset_failed(&error); + return Err(error); + } self.scoped_operation_completions.clear(); } @@ -509,8 +541,7 @@ impl HostRuntime { .begin_close(crate::vm::resource::ResourceCloseReason::VmReset); } self.scoped_operation_completions.clear(); - self.execution_scope - .cancel_operations_and_wait(crate::vm::operation::OperationCancelReason::VmReset); + self.stream_drivers.clear(); self.replacement_execution_scope = None; self.scope_reset_pending = false; self.scope_reset_error = Some(error); @@ -527,9 +558,15 @@ impl HostRuntime { if let Some(error) = self.scope_reset_error.clone() { return Poll::Ready(Err(VmError::ExecutionScope(error))); } + if let Some(error) = self.reset_error.clone() { + return Poll::Ready(Err(VmError::HostError(error))); + } match self.poll_bridge_operations(cx) { Poll::Pending => return Poll::Pending, - Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Err(error)) => { + self.mark_reset_failed(&error); + return Poll::Ready(Err(error)); + } Poll::Ready(Ok(())) => {} } if !self.scope_reset_pending { @@ -549,6 +586,8 @@ impl HostRuntime { .take() .expect("pending scope reset must retain one replacement scope"); self.execution_scope = replacement; + self.stream_drivers.clear(); + self.pending_stream_terminations.clear(); self.scope_reset_pending = false; Poll::Ready(Ok(())) } @@ -564,16 +603,179 @@ impl HostRuntime { } } + pub(crate) fn begin_stream_termination( + &mut self, + op_id: HostOpId, + termination: HostStreamTermination, + ) -> VmResult<()> { + if self.pending_stream_terminations.contains_key(&op_id) { + return Ok(()); + } + let Some(mut driver) = self.stream_drivers.remove(&op_id) else { + return Err(VmError::HostError(format!( + "missing callable stream driver {op_id}" + ))); + }; + if let Err(error) = driver.begin_termination(&mut self.execution_scope, termination) { + self.pending_stream_terminations.insert( + op_id, + PendingHostStreamTermination { + driver, + termination, + admission_error: None, + termination_started: false, + cleanup_error: Some(VmError::HostError(error.to_string())), + }, + ); + return Err(error); + } + self.pending_stream_terminations.insert( + op_id, + PendingHostStreamTermination { + driver, + termination, + admission_error: None, + termination_started: true, + cleanup_error: None, + }, + ); + Ok(()) + } + + #[allow(dead_code)] + pub(crate) fn retain_stream_admission_rollback( + &mut self, + rollback: HostStreamAdmissionRollback, + primary: VmError, + ) -> HostOpId { + let op_id = self.next_host_op_id; + self.next_host_op_id = self.next_host_op_id.wrapping_add(1).max(1); + self.pending_stream_terminations.insert( + op_id, + PendingHostStreamTermination { + driver: rollback.driver, + termination: rollback.termination, + admission_error: Some(primary), + termination_started: false, + cleanup_error: None, + }, + ); + op_id + } + + pub(crate) fn poll_stream_terminations(&mut self, cx: &mut Context<'_>) -> Poll> { + let ids: Vec = self.pending_stream_terminations.keys().copied().collect(); + let has_admission_rollback = ids.iter().any(|op_id| { + self.pending_stream_terminations + .get(op_id) + .is_some_and(|pending| pending.admission_error.is_some()) + }); + let mut completed = Vec::new(); + let mut first_error = None; + let mut immediate_cleanup_error = false; + for op_id in ids { + let Some(pending) = self.pending_stream_terminations.get_mut(&op_id) else { + continue; + }; + if !pending.termination_started { + match pending + .driver + .begin_termination(&mut self.execution_scope, pending.termination) + { + Ok(()) => pending.termination_started = true, + Err(error) => { + if pending.cleanup_error.is_none() { + pending.cleanup_error = Some(error); + } + if let Some(primary) = pending.admission_error.as_ref() { + if first_error.is_none() { + first_error = Some(VmError::HostError(format!( + "{primary}; cleanup failed: {}", + pending + .cleanup_error + .as_ref() + .expect("cleanup error recorded"), + ))); + } + immediate_cleanup_error = true; + } + continue; + } + } + } + match pending.driver.poll_termination( + &mut self.execution_scope, + pending.termination, + cx, + ) { + Poll::Pending => {} + Poll::Ready(Ok(())) => completed.push(op_id), + Poll::Ready(Err(error)) => { + completed.push(op_id); + if pending.cleanup_error.is_none() { + pending.cleanup_error = Some(error); + } + } + } + } + for op_id in completed { + if let Some(pending) = self.pending_stream_terminations.remove(&op_id) { + let cleanup = pending.cleanup_error; + let error = match (pending.admission_error, cleanup) { + (Some(primary), Some(cleanup)) => { + preserve_stream_cleanup(primary, Err(cleanup)) + } + (Some(primary), None) => primary, + (None, Some(cleanup)) => cleanup, + (None, None) => continue, + }; + if first_error.is_none() { + first_error = Some(error); + } + } + } + if immediate_cleanup_error { + Poll::Ready(Err(first_error.expect("cleanup error recorded"))) + } else if has_admission_rollback && !self.pending_stream_terminations.is_empty() { + Poll::Pending + } else if let Some(error) = first_error { + Poll::Ready(Err(error)) + } else if self.pending_stream_terminations.is_empty() { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + + pub(crate) fn has_pending_stream_terminations(&self) -> bool { + !self.pending_stream_terminations.is_empty() + } + pub(crate) fn scope_reset_error(&self) -> Option<&ExecutionScopeError> { self.scope_reset_error.as_ref() } + pub(crate) fn mark_reset_failed(&mut self, error: &VmError) { + if self.scope_reset_error.is_none() && self.reset_error.is_none() { + self.reset_error = Some(error.to_string()); + } + } + + pub(crate) fn reset_error(&self) -> Option { + self.reset_error + .as_ref() + .map(|error| VmError::HostError(error.clone())) + } + pub(crate) fn is_reusable(&self) -> bool { !self.scope_reset_pending && self.scope_reset_error.is_none() + && self.reset_error.is_none() && self.execution_scope.is_reusable() && self.bridge_operations.is_empty() && self.scoped_operation_completions.is_empty() + && self.stream_drivers.is_empty() + && self.pending_stream_terminations.is_empty() } } diff --git a/src/vm/instance.rs b/src/vm/instance.rs index fe550c37..ad52c461 100644 --- a/src/vm/instance.rs +++ b/src/vm/instance.rs @@ -18,6 +18,7 @@ use std::sync::atomic::AtomicBool; use std::sync::{Arc, Weak}; use crate::bytecode::{CallableValue, MAX_FRAME_LOCAL_COUNT, Program, SharedCaptureCell, Value}; +use crate::vm::async_host::HostStreamContinuation; use crate::vm::host::WaitingHostOp; use crate::vm::invocation::{InvocationPhase, InvocationState}; use crate::vm::map_iter::MapIteratorState; @@ -85,6 +86,7 @@ pub(crate) struct Instance { pub(crate) draining_queued_callables: bool, pub(crate) shutdown: bool, pub(super) waiting_host_op: Option, + pub(crate) host_stream: Option, pub(crate) last_yield_reason: Option, pub(crate) invocation: Option, pub(crate) map_iterators: Vec>>, @@ -127,6 +129,7 @@ impl Instance { draining_queued_callables: false, shutdown: false, waiting_host_op: None, + host_stream: None, last_yield_reason: None, invocation: None, map_iterators: Vec::new(), @@ -170,6 +173,7 @@ impl Instance { self.draining_queued_callables = false; self.shutdown = false; self.waiting_host_op = None; + self.host_stream = None; self.drop_invocation_state(); self.invocation = None; self.map_iterators.clear(); @@ -190,6 +194,7 @@ impl Instance { || self.draining_queued_callables || self.shutdown || self.waiting_host_op.is_some() + || self.host_stream.is_some() || self.last_yield_reason.is_some() || self.map_iterators.iter().flatten().any(Option::is_some) { diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 7841d344..2d34742d 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -35,6 +35,7 @@ mod superinstructions; #[cfg(test)] mod tests; pub use self::aot::AotArtifactError; +use self::async_host::preserve_stream_cleanup; pub use self::async_host::{CaptureAsyncHostContext, HostFuture, HostFutureOutput}; pub use self::capability::{CapabilityProfile, CapabilityProfileBuilder}; use self::engine::Engine; @@ -53,8 +54,9 @@ pub use self::host_context::{ }; pub use self::host_extension::{ CatalogRegistrationError, CatalogSchemaSelection, HostExtension, HostImportParam, - HostImportSchema, catalog_import_schemas, register_catalog_function, - register_catalog_static_function, validate_catalog_import_schemas, + HostImportSchema, catalog_import_schemas, catalog_import_schemas_into, + catalog_named_struct_schemas, register_catalog_function, register_catalog_static_function, + register_host_extension, validate_catalog_import_schemas, validate_catalog_import_schemas_with_fingerprints, }; use self::host_runtime::HostRuntime; @@ -440,6 +442,14 @@ fn compute_program_cache_key(program: &Program) -> u64 { } } hash_type_map(program.type_map.as_ref(), &mut hasher); + let mut named_struct_decls = program.named_struct_decls.iter().collect::>(); + named_struct_decls.sort_unstable_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs)); + named_struct_decls.len().hash(&mut hasher); + for (name, decl) in named_struct_decls { + name.hash(&mut hasher); + decl.type_params.hash(&mut hasher); + hash_type_schema(&decl.body_schema, &mut hasher); + } hasher.finish() } @@ -477,14 +487,158 @@ fn hash_local_schemas(schemas: &[Option], state: &m } } +#[derive(Clone, Copy)] +enum NamedStructOrigin { + Host, + Guest, +} + +#[derive(Clone, Copy)] +struct NamedStructLookup<'a> { + host: &'a HashMap, + guest: &'a HashMap, +} + +impl<'a> NamedStructLookup<'a> { + fn body( + self, + name: &str, + args: &[crate::compiler::TypeSchema], + ) -> Option<(crate::compiler::TypeSchema, NamedStructOrigin)> { + if let Some(body) = self.host.get(name) { + if !args.is_empty() { + return None; + } + return Some((body.clone(), NamedStructOrigin::Host)); + } + let decl = self.guest.get(name)?; + if decl.type_params.len() != args.len() { + return None; + } + let body = if args.is_empty() { + decl.body_schema.clone() + } else { + let bindings = decl + .type_params + .iter() + .cloned() + .zip(args.iter().cloned()) + .collect::>(); + substitute_named_struct_schema(&decl.body_schema, &bindings) + }; + Some((body, NamedStructOrigin::Guest)) + } +} + +fn substitute_named_struct_schema( + schema: &crate::compiler::TypeSchema, + bindings: &HashMap, +) -> crate::compiler::TypeSchema { + use crate::compiler::TypeSchema; + + match schema { + TypeSchema::GenericParam(name) => bindings + .get(name) + .cloned() + .unwrap_or_else(|| schema.clone()), + TypeSchema::Optional(inner) => { + TypeSchema::Optional(Box::new(substitute_named_struct_schema(inner, bindings))) + } + TypeSchema::Named(name, args) => TypeSchema::Named( + name.clone(), + args.iter() + .map(|arg| substitute_named_struct_schema(arg, bindings)) + .collect(), + ), + TypeSchema::Array(inner) => { + TypeSchema::Array(Box::new(substitute_named_struct_schema(inner, bindings))) + } + TypeSchema::ArrayTuple(items) => TypeSchema::ArrayTuple( + items + .iter() + .map(|item| substitute_named_struct_schema(item, bindings)) + .collect(), + ), + TypeSchema::ArrayTupleRest { prefix, rest } => TypeSchema::ArrayTupleRest { + prefix: prefix + .iter() + .map(|item| substitute_named_struct_schema(item, bindings)) + .collect(), + rest: Box::new(substitute_named_struct_schema(rest, bindings)), + }, + TypeSchema::Map(inner) => { + TypeSchema::Map(Box::new(substitute_named_struct_schema(inner, bindings))) + } + TypeSchema::Object(fields) => TypeSchema::Object( + fields + .iter() + .map(|(name, field)| { + ( + name.clone(), + substitute_named_struct_schema(field, bindings), + ) + }) + .collect(), + ), + TypeSchema::Callable { params, result } => TypeSchema::Callable { + params: params + .iter() + .map(|param| substitute_named_struct_schema(param, bindings)) + .collect(), + result: Box::new(substitute_named_struct_schema(result, bindings)), + }, + _ => schema.clone(), + } +} + fn validate_value_against_type_schema( value: &Value, schema: &crate::compiler::TypeSchema, resources: &ResourceTable, validate_scalars: bool, + named_struct_schemas: NamedStructLookup<'_>, +) -> VmResult<()> { + validate_value_against_type_schema_walk( + value, + schema, + resources, + validate_scalars, + named_struct_schemas, + 0, + &mut 0, + ) +} + +fn charge_named_schema_node(nodes: &mut usize) -> VmResult<()> { + *nodes = nodes.saturating_add(1); + if *nodes > crate::host_api::MAX_HOST_SCHEMA_NODES { + return Err(VmError::HostError(format!( + "named struct schema exceeded complexity limit {}", + crate::host_api::MAX_HOST_SCHEMA_NODES + ))); + } + Ok(()) +} + +fn validate_value_against_type_schema_walk( + value: &Value, + schema: &crate::compiler::TypeSchema, + resources: &ResourceTable, + validate_scalars: bool, + named_struct_schemas: NamedStructLookup<'_>, + depth: usize, + nodes: &mut usize, ) -> VmResult<()> { use crate::compiler::TypeSchema; + charge_named_schema_node(nodes)?; + if depth > crate::host_api::MAX_HOST_SCHEMA_DEPTH { + return Err(VmError::HostError(format!( + "named struct schema exceeded depth limit {}", + crate::host_api::MAX_HOST_SCHEMA_DEPTH + ))); + } + match schema { TypeSchema::Unknown | TypeSchema::GenericParam(_) => Ok(()), TypeSchema::Null => { @@ -540,25 +694,62 @@ fn validate_value_against_type_schema( if matches!(value, Value::Null) { Ok(()) } else { - validate_value_against_type_schema(value, inner, resources, validate_scalars) + validate_value_against_type_schema_walk( + value, + inner, + resources, + validate_scalars, + named_struct_schemas, + depth + 1, + nodes, + ) } } - TypeSchema::Named(_, _) => { - if matches!(value, Value::Map(_)) { - Ok(()) - } else { - Err(VmError::TypeMismatch("map")) + TypeSchema::Named(name, args) => { + if !matches!(value, Value::Map(_)) { + return Err(VmError::TypeMismatch("map")); + } + let Some((body, origin)) = named_struct_schemas.body(name, args) else { + return Err(VmError::HostError(format!("unknown named struct '{name}'"))); + }; + match &body { + TypeSchema::Object(fields) => validate_named_object_fields( + value, + fields, + resources, + matches!(origin, NamedStructOrigin::Host), + named_struct_schemas, + depth + 1, + nodes, + ), + _ => validate_value_against_type_schema_walk( + value, + &body, + resources, + true, + named_struct_schemas, + depth + 1, + nodes, + ), } } TypeSchema::Map(inner) => { let Value::Map(values) = value else { return Err(VmError::TypeMismatch("map")); }; - if !schema_contains_resource(inner) { + if !schema_contains_resource(inner, named_struct_schemas) { return Ok(()); } for (_, value) in values.iter() { - validate_value_against_type_schema(value, inner, resources, false)?; + validate_value_against_type_schema_walk( + value, + inner, + resources, + false, + named_struct_schemas, + depth + 1, + nodes, + )?; } Ok(()) } @@ -567,11 +758,19 @@ fn validate_value_against_type_schema( return Err(VmError::TypeMismatch("object")); }; for (name, field_schema) in fields { - if !schema_contains_resource(field_schema) { + if !schema_contains_resource(field_schema, named_struct_schemas) { continue; } if let Some(field) = values.get(&Value::string(name)) { - validate_value_against_type_schema(field, field_schema, resources, false)?; + validate_value_against_type_schema_walk( + field, + field_schema, + resources, + false, + named_struct_schemas, + depth + 1, + nodes, + )?; } } Ok(()) @@ -580,11 +779,19 @@ fn validate_value_against_type_schema( let Value::Array(values) = value else { return Err(VmError::TypeMismatch("array")); }; - if !schema_contains_resource(inner) { + if !schema_contains_resource(inner, named_struct_schemas) { return Ok(()); } for value in values.iter() { - validate_value_against_type_schema(value, inner, resources, false)?; + validate_value_against_type_schema_walk( + value, + inner, + resources, + false, + named_struct_schemas, + depth + 1, + nodes, + )?; } Ok(()) } @@ -592,15 +799,23 @@ fn validate_value_against_type_schema( let Value::Array(values) = value else { return Err(VmError::TypeMismatch("tuple")); }; - if !schema_contains_resource(schema) { + if !schema_contains_resource(schema, named_struct_schemas) { return Ok(()); } if values.len() != items.len() { return Err(VmError::TypeMismatch("tuple")); } for (value, item) in values.iter().zip(items) { - if schema_contains_resource(item) { - validate_value_against_type_schema(value, item, resources, false)?; + if schema_contains_resource(item, named_struct_schemas) { + validate_value_against_type_schema_walk( + value, + item, + resources, + false, + named_struct_schemas, + depth + 1, + nodes, + )?; } } Ok(()) @@ -609,20 +824,36 @@ fn validate_value_against_type_schema( let Value::Array(values) = value else { return Err(VmError::TypeMismatch("tuple")); }; - if !schema_contains_resource(schema) { + if !schema_contains_resource(schema, named_struct_schemas) { return Ok(()); } if values.len() < prefix.len() { return Err(VmError::TypeMismatch("tuple")); } for (value, item) in values.iter().zip(prefix) { - if schema_contains_resource(item) { - validate_value_against_type_schema(value, item, resources, false)?; + if schema_contains_resource(item, named_struct_schemas) { + validate_value_against_type_schema_walk( + value, + item, + resources, + false, + named_struct_schemas, + depth + 1, + nodes, + )?; } } - if schema_contains_resource(rest) { + if schema_contains_resource(rest, named_struct_schemas) { for value in values.iter().skip(prefix.len()) { - validate_value_against_type_schema(value, rest, resources, false)?; + validate_value_against_type_schema_walk( + value, + rest, + resources, + false, + named_struct_schemas, + depth + 1, + nodes, + )?; } } Ok(()) @@ -647,30 +878,117 @@ fn validate_value_against_type_schema( } } -fn schema_contains_resource(schema: &crate::compiler::TypeSchema) -> bool { +fn validate_named_object_fields( + value: &Value, + fields: &HashMap, + resources: &ResourceTable, + validate_scalars: bool, + named_struct_schemas: NamedStructLookup<'_>, + depth: usize, + nodes: &mut usize, +) -> VmResult<()> { use crate::compiler::TypeSchema; + let Value::Map(values) = value else { + return Err(VmError::TypeMismatch("map")); + }; + for (name, field_schema) in fields { + match values.get(&Value::string(name)) { + Some(field) => validate_value_against_type_schema_walk( + field, + field_schema, + resources, + validate_scalars, + named_struct_schemas, + depth, + nodes, + )?, + None if matches!(field_schema, TypeSchema::Optional(_)) + || (!validate_scalars + && !schema_contains_resource(field_schema, named_struct_schemas)) => {} + None => return Err(VmError::TypeMismatch("object")), + } + } + Ok(()) +} + +fn schema_contains_resource( + schema: &crate::compiler::TypeSchema, + named_struct_schemas: NamedStructLookup<'_>, +) -> bool { + schema_contains_resource_walk(schema, named_struct_schemas, 0, &mut HashSet::new(), &mut 0) +} + +fn schema_contains_resource_walk( + schema: &crate::compiler::TypeSchema, + named_struct_schemas: NamedStructLookup<'_>, + depth: usize, + active: &mut HashSet, + nodes: &mut usize, +) -> bool { + use crate::compiler::TypeSchema; + + *nodes = nodes.saturating_add(1); + if depth > crate::host_api::MAX_HOST_SCHEMA_DEPTH + || *nodes > crate::host_api::MAX_HOST_SCHEMA_NODES + { + return true; + } + match schema { TypeSchema::Resource(_) => true, TypeSchema::Optional(inner) | TypeSchema::Array(inner) | TypeSchema::Map(inner) => { - schema_contains_resource(inner) - } - TypeSchema::Object(fields) => fields.values().any(schema_contains_resource), - TypeSchema::ArrayTuple(items) => items.iter().any(schema_contains_resource), + schema_contains_resource_walk(inner, named_struct_schemas, depth + 1, active, nodes) + } + TypeSchema::Object(fields) => fields.values().any(|field| { + schema_contains_resource_walk(field, named_struct_schemas, depth + 1, active, nodes) + }), + TypeSchema::ArrayTuple(items) => items.iter().any(|item| { + schema_contains_resource_walk(item, named_struct_schemas, depth + 1, active, nodes) + }), TypeSchema::ArrayTupleRest { prefix, rest } => { - prefix.iter().any(schema_contains_resource) || schema_contains_resource(rest) + prefix.iter().any(|item| { + schema_contains_resource_walk(item, named_struct_schemas, depth + 1, active, nodes) + }) || schema_contains_resource_walk( + rest, + named_struct_schemas, + depth + 1, + active, + nodes, + ) } - TypeSchema::Callable { .. } + TypeSchema::Named(name, args) => { + if args.iter().any(|arg| { + schema_contains_resource_walk(arg, named_struct_schemas, depth + 1, active, nodes) + }) { + return true; + } + let Some((body, _)) = named_struct_schemas.body(name, args) else { + return true; + }; + if !active.insert(name.clone()) { + return false; + } + let contains = schema_contains_resource_walk( + &body, + named_struct_schemas, + depth + 1, + active, + nodes, + ); + active.remove(name); + contains + } + TypeSchema::GenericParam(_) + | TypeSchema::Callable { .. } | TypeSchema::Unknown - | TypeSchema::GenericParam(_) | TypeSchema::Null | TypeSchema::Int | TypeSchema::Float | TypeSchema::Number | TypeSchema::Bool | TypeSchema::String - | TypeSchema::Bytes - | TypeSchema::Named(_, _) => false, + | TypeSchema::Bytes => false, } } @@ -976,11 +1294,28 @@ impl Vm { /// retired through the generic execution-scope lifecycle. If generic close /// is still pending, the old scope remains retained and VM execution is /// blocked until `poll_reset_for_reuse` reaches quiescence. + /// A successful return only starts the reset; callers must poll + /// `poll_reset_for_reuse` to obtain the deterministic completion result + /// before observing an empty scope or reusing the VM. pub fn reset_for_reuse(&mut self) -> VmResult<()> { - validate_frame_allocation_limits(&self.program)?; - self.cancel_waiting_host_op_with_reason( + if let Err(error) = validate_frame_allocation_limits(&self.program) { + self.host.mark_reset_failed(&error); + return Err(error); + } + let waiting_cleanup = self.cancel_waiting_host_op_with_reason( crate::vm::operation::OperationCancelReason::VmReset, - )?; + ); + let stream_cleanup = self.cancel_callable_stream_with_reason( + crate::vm::operation::OperationCancelReason::VmReset, + ); + let cleanup_result = match waiting_cleanup { + Ok(()) => stream_cleanup, + Err(error) => Err(preserve_stream_cleanup(error, stream_cleanup)), + }; + if let Err(error) = cleanup_result { + self.host.mark_reset_failed(&error); + return Err(error); + } if let Err(error) = self.host.reset_execution_scope() { self.instance.invalidate_callback_registries(); return Err(error); @@ -1032,6 +1367,22 @@ impl Vm { if let Some(error) = self.host.scope_reset_error().cloned() { return Err(VmError::ExecutionScope(error)); } + if let Some(error) = self.host.reset_error() { + return Err(error); + } + if self.host.has_pending_stream_terminations() { + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + match self.host.poll_stream_terminations(&mut cx) { + Poll::Ready(Ok(())) => {} + Poll::Ready(Err(error)) => return Err(error), + Poll::Pending => { + return Err(VmError::HostError( + "callable stream termination is not quiescent".to_string(), + )); + } + } + } if !self.host.scope_reset_pending { if self.host.has_pending_bridge_cancellations() { return Err(VmError::HostError( @@ -1330,7 +1681,14 @@ impl Vm { pub fn run(&mut self) -> VmResult { self.ensure_scope_ready()?; - self.run_internal(None, true) + let status = match self.run_internal(None, true) { + Ok(status) => status, + Err(error) => { + let cleanup = self.abort_callable_stream_on_run_error(); + return Err(preserve_stream_cleanup(error, cleanup)); + } + }; + self.resume_callable_stream_after_run(status) } pub fn run_with_debugger( @@ -1338,7 +1696,14 @@ impl Vm { debugger: &mut crate::debugger::Debugger, ) -> VmResult { self.ensure_scope_ready()?; - self.run_internal(Some(debugger), false) + let status = match self.run_internal(Some(debugger), false) { + Ok(status) => status, + Err(error) => { + let cleanup = self.abort_callable_stream_on_run_error(); + return Err(preserve_stream_cleanup(error, cleanup)); + } + }; + self.resume_callable_stream_after_run(status) } } @@ -1347,6 +1712,12 @@ impl Drop for Vm { let _ = self.cancel_waiting_host_op_with_reason( crate::vm::operation::OperationCancelReason::VmDrop, ); + let _ = self.cancel_callable_stream_with_reason( + crate::vm::operation::OperationCancelReason::VmDrop, + ); + let _ = self.terminate_all_callable_streams_with_reason( + crate::vm::operation::OperationCancelReason::VmDrop, + ); self.host .cancel_submitted_host_ops(crate::vm::operation::OperationCancelReason::VmDrop); self.instance.drop_cleanup(); @@ -1552,6 +1923,10 @@ impl Vm { schema, self.host.execution_scope.resources(), true, + NamedStructLookup { + host: &self.host.named_struct_schemas, + guest: &self.program.named_struct_decls, + }, ) { return Err(map_callable_schema_error(error, "callable argument schema")); } @@ -1830,6 +2205,10 @@ impl Vm { schema, self.host.execution_scope.resources(), true, + NamedStructLookup { + host: &self.host.named_struct_schemas, + guest: &self.program.named_struct_decls, + }, ) { self.drop_value_with_contract(result); @@ -3110,7 +3489,14 @@ impl Vm { .map(|frame| &frame.continuation), Some(FrameContinuation::ReturnToHost) ); - self.run_internal(None, allow_jit) + let status = match self.run_internal(None, allow_jit) { + Ok(status) => status, + Err(error) => { + let cleanup = self.abort_callable_stream_on_run_error(); + return Err(preserve_stream_cleanup(error, cleanup)); + } + }; + self.resume_callable_stream_after_run(status) } pub fn stack(&self) -> &[Value] { @@ -3318,15 +3704,24 @@ impl Vm { let _ = self.cancel_waiting_host_op_with_reason( crate::vm::operation::OperationCancelReason::VmDrop, ); + let _ = self.cancel_callable_stream_with_reason( + crate::vm::operation::OperationCancelReason::VmDrop, + ); + let _ = self.terminate_all_callable_streams_with_reason( + crate::vm::operation::OperationCancelReason::VmDrop, + ); self.host .cancel_submitted_host_ops(crate::vm::operation::OperationCancelReason::VmDrop); - self.host.scoped_operation_completions.clear(); // Begin execution-scope shutdown (first-reason-wins; sealing the // operation registry) before tearing down interpreter state. let _ = self .host .execution_scope .begin_close(crate::vm::resource::ResourceCloseReason::VmDrop); + self.host.scoped_operation_completions.clear(); + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + let _ = self.host.execution_scope.poll_close(&mut cx); self.instance.queued_callables.clear(); self.instance.completed_callable_results.clear(); self.instance.owned_callables.clear(); diff --git a/src/vm/operation/driver.rs b/src/vm/operation/driver.rs index d91ec8bc..4491e4c2 100644 --- a/src/vm/operation/driver.rs +++ b/src/vm/operation/driver.rs @@ -73,6 +73,26 @@ pub trait HostOperation: Any + Send + 'static { /// Registers a waker for the transition to quiescent after cancellation. fn register_quiescence_waker(&mut self, _cx: &Context<'_>) {} + /// Polls the transition to quiescence without a check-then-register race. + /// + /// The default compatibility implementation deliberately keeps the + /// existing `is_quiescent` and `register_quiescence_waker` hooks: it checks + /// once, registers the caller's waker, then checks again. A driver that + /// publishes quiescence between those two operations is therefore observed + /// in this poll, while a publication after the second check wakes the + /// registered task. + fn poll_quiescent(&mut self, cx: &mut Context<'_>) -> Poll<()> { + if self.is_quiescent() { + return Poll::Ready(()); + } + self.register_quiescence_waker(cx); + if self.is_quiescent() { + Poll::Ready(()) + } else { + Poll::Pending + } + } + /// Cancels and waits for the driver's worker to terminate. /// /// This method is the cancellation/quiescence boundary. Implementations diff --git a/src/vm/operation/registry.rs b/src/vm/operation/registry.rs index b3ef9859..9c0947d1 100644 --- a/src/vm/operation/registry.rs +++ b/src/vm/operation/registry.rs @@ -298,12 +298,12 @@ impl OperationRegistry { Ok(slot) => slot, Err(error) => return Poll::Ready(Err(error)), }; - let operation = self.slots[slot] + let quiescent = self.slots[slot] .operation .as_mut() - .expect("cancelled deadline operation remains occupied"); - if !operation.driver.is_quiescent() { - operation.driver.register_quiescence_waker(cx); + .map(|operation| operation.driver.poll_quiescent(cx)) + .unwrap_or(Poll::Ready(())); + if quiescent.is_pending() { return Poll::Pending; } Poll::Ready(Ok(self.consume_terminal(slot))) @@ -337,6 +337,29 @@ impl OperationRegistry { } } + /// Polls a terminal operation until its driver has quiesced, then consumes + /// the terminal slot. This is the non-driving half of [`poll`](Self::poll) + /// used by asynchronous cleanup owners that have already requested a + /// cancellation or completion. + pub fn poll_quiescent( + &mut self, + id: OperationId, + cx: &mut Context<'_>, + ) -> Poll> { + let slot = match self.location(id) { + Ok(slot) => slot, + Err(error) => return Poll::Ready(Err(error)), + }; + if !self.slots[slot] + .operation + .as_ref() + .is_some_and(|operation| operation.status.is_terminal()) + { + return Poll::Pending; + } + self.poll_terminal(slot, cx) + } + /// Cancels one operation, forwarding the reason to its driver. /// /// The id is validated before any mutation, and the driver's @@ -529,10 +552,8 @@ impl OperationRegistry { if !operation.status.is_terminal() { continue; } - if operation.driver.is_quiescent() { + if operation.driver.poll_quiescent(cx).is_ready() { let _ = self.consume_terminal(slot); - } else { - operation.driver.register_quiescence_waker(cx); } } self.is_empty() @@ -656,12 +677,7 @@ impl OperationRegistry { .operation .as_mut() .expect("terminal slot remains occupied"); - if operation.driver.is_quiescent() { - true - } else { - operation.driver.register_quiescence_waker(cx); - false - } + operation.driver.poll_quiescent(cx).is_ready() }; if quiescent { Poll::Ready(Ok(self.consume_terminal(slot))) @@ -1364,6 +1380,82 @@ mod tests { assert_eq!(registry.len(), 0); } + /// The worker may finish after the first quiescence observation but before + /// the driver's waker registration. The registry must perform the second + /// observation in the same poll, otherwise this terminal operation can be + /// stranded forever with no future wakeup. + struct CompletesDuringQuiescenceRegistration { + quiescent: bool, + registrations: Arc, + } + + impl HostOperation for CompletesDuringQuiescenceRegistration { + fn poll(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + + fn cancel(&mut self, _reason: OperationCancelReason) -> OperationResult<()> { + Ok(()) + } + + fn is_quiescent(&self) -> bool { + self.quiescent + } + + fn register_quiescence_waker(&mut self, _cx: &Context<'_>) { + self.registrations.fetch_add(1, Ordering::SeqCst); + self.quiescent = true; + } + } + + #[test] + fn poll_quiescence_rechecks_after_registration() { + let registrations = Arc::new(AtomicUsize::new(0)); + let mut registry = OperationRegistry::with_limit(1).expect("registry"); + let id = registry + .start(OperationSpec::new(CompletesDuringQuiescenceRegistration { + quiescent: false, + registrations: Arc::clone(®istrations), + })) + .expect("start"); + registry + .cancel(id, OperationCancelReason::VmReset) + .expect("cancel"); + + let (waker, _) = test_waker(); + let mut cx = Context::from_waker(&waker); + assert!(registry.poll_quiescence(&mut cx)); + assert_eq!(registrations.load(Ordering::SeqCst), 1); + assert!(registry.is_empty()); + } + + #[test] + fn terminal_poll_rechecks_after_registration_across_many_iterations() { + let (waker, _) = test_waker(); + let mut cx = Context::from_waker(&waker); + for _ in 0..256 { + let registrations = Arc::new(AtomicUsize::new(0)); + let mut registry = OperationRegistry::with_limit(1).expect("registry"); + let id = registry + .start(OperationSpec::new(CompletesDuringQuiescenceRegistration { + quiescent: false, + registrations: Arc::clone(®istrations), + })) + .expect("start"); + registry + .cancel(id, OperationCancelReason::Requested) + .expect("cancel"); + assert_eq!( + registry.poll(id, &mut cx), + Poll::Ready(Ok(OperationOutcome::Cancelled( + OperationCancelReason::Requested + ))) + ); + assert_eq!(registrations.load(Ordering::SeqCst), 1); + assert!(registry.is_empty()); + } + } + #[test] fn cleanup_runs_exactly_once_on_terminal_transition() { let mut registry = OperationRegistry::with_limit(2).expect("registry"); diff --git a/src/vm/tests.rs b/src/vm/tests.rs index 00e4352b..24bd25d9 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -3455,7 +3455,7 @@ fn native_callable_abi_version_covers_direct_script_calls() { mod callable_resource_schema_tests { use super::*; use crate::bytecode::VmMap; - use crate::compiler::TypeSchema; + use crate::compiler::{StructDecl, TypeSchema}; use crate::vm::resource::{CloseProgress, HostResource, ResourceCloseReason, ResourceHandle}; use crate::{CallableKind, CallablePrototype, FunctionRegion, ScriptFunction}; @@ -3700,4 +3700,762 @@ mod callable_resource_schema_tests { .expect_err("nested arbitrary ints must not satisfy resources"); assert_resource_schema_error(error, "invalid_resource_handle"); } + + fn handle_box_schema() -> (TypeSchema, HashMap) { + let mut fields = HashMap::new(); + fields.insert("file".to_string(), TypeSchema::Resource(resource_key())); + fields.insert( + "note".to_string(), + TypeSchema::Optional(Box::new(TypeSchema::String)), + ); + let mut named = HashMap::new(); + named.insert("HandleBox".to_string(), TypeSchema::Object(fields)); + ( + TypeSchema::Named("HandleBox".to_string(), Vec::new()), + named, + ) + } + + fn callable_vm_with_named( + callee_body: &[u8], + constants: Vec, + parameter_schema: TypeSchema, + result_schema: TypeSchema, + named: HashMap, + ) -> (Vm, Value) { + let (mut vm, callable) = + callable_vm(callee_body, constants, parameter_schema, result_schema); + vm.host.named_struct_schemas = Arc::new(named); + (vm, callable) + } + + fn callable_vm_with_guest_named( + callee_body: &[u8], + constants: Vec, + parameter_schema: TypeSchema, + result_schema: TypeSchema, + guest: HashMap, + ) -> (Vm, Value) { + let function_entry = 1u32; + let function_end = function_entry + callee_body.len() as u32; + let mut code = vec![OpCode::Ret as u8]; + code.extend_from_slice(callee_body); + let program = Program::new(constants, code) + .with_local_count(0) + .with_callable_metadata( + vec![ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 1, + frame_local_count: 1, + parameter_slots: vec![0], + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: Some(TypeSchema::Callable { + params: vec![parameter_schema], + result: Box::new(result_schema), + }), + }], + vec![ + FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + Vec::new(), + ) + .with_named_struct_decls(guest); + let mut vm = Vm::new(program); + let callable = vm + .bind_callable_value(0, Vec::new()) + .expect("callable should bind"); + vm.run().expect("root program should halt"); + (vm, callable) + } + + fn point_guest_decl() -> HashMap { + let mut fields = HashMap::new(); + fields.insert("x".to_string(), TypeSchema::Int); + fields.insert("y".to_string(), TypeSchema::Int); + let mut guest = HashMap::new(); + guest.insert( + "Point".to_string(), + StructDecl { + name: "Point".to_string(), + type_params: Vec::new(), + body_schema: TypeSchema::Object(fields), + origin: crate::compiler::StructDeclOrigin::Guest, + }, + ); + guest + } + + fn empty_map() -> Value { + Value::Map(VmMap::new().into()) + } + + fn guest_handle_box_decl() -> HashMap { + let mut fields = HashMap::new(); + fields.insert("file".to_string(), TypeSchema::Resource(resource_key())); + fields.insert( + "note".to_string(), + TypeSchema::Optional(Box::new(TypeSchema::String)), + ); + let mut guest = HashMap::new(); + guest.insert( + "HandleBox".to_string(), + StructDecl { + name: "HandleBox".to_string(), + type_params: Vec::new(), + body_schema: TypeSchema::Object(fields), + origin: crate::compiler::StructDeclOrigin::Guest, + }, + ); + guest + } + + fn guest_holder_decl() -> HashMap { + let mut fields = HashMap::new(); + fields.insert( + "value".to_string(), + TypeSchema::GenericParam("T".to_string()), + ); + let mut guest = HashMap::new(); + guest.insert( + "Holder".to_string(), + StructDecl { + name: "Holder".to_string(), + type_params: vec!["T".to_string()], + body_schema: TypeSchema::Object(fields), + origin: crate::compiler::StructDeclOrigin::Guest, + }, + ); + guest + } + + fn assert_callable_schema_error(error: VmError) { + assert!( + matches!(error, VmError::TypeMismatch("callable argument schema")) + || matches!(error, VmError::HostError(_)), + "unexpected error: {error:?}" + ); + } + + fn named_handle_box(handle: i64, note: Option<&str>) -> Value { + let mut object = VmMap::new(); + object.insert(Value::string("file"), Value::Int(handle)); + if let Some(note) = note { + object.insert(Value::string("note"), Value::string(note)); + } + Value::Map(object.into()) + } + + #[test] + fn named_struct_with_resource_field_validates_nested_handle() { + let (schema, named) = handle_box_schema(); + let (mut vm, callable) = callable_vm_with_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + named, + ); + let handle = resource_handle(&mut vm); + assert_eq!( + vm.invoke_callable(callable.clone(), &[named_handle_box(handle, Some("ok"))]) + .expect("named struct with live resource should pass"), + Value::Null + ); + + let error = vm + .invoke_callable(callable, &[named_handle_box(41, Some("bad"))]) + .expect_err("named struct must reject invalid nested resource"); + assert_resource_schema_error(error, "invalid_resource_handle"); + } + + #[test] + fn named_struct_optional_field_may_be_omitted() { + let (schema, named) = handle_box_schema(); + let (mut vm, callable) = callable_vm_with_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + named, + ); + let handle = resource_handle(&mut vm); + assert_eq!( + vm.invoke_callable(callable, &[named_handle_box(handle, None)]) + .expect("optional named field may be omitted"), + Value::Null + ); + } + + #[test] + fn named_struct_nested_array_validates_resource_elements() { + let (element, named) = handle_box_schema(); + let schema = TypeSchema::Array(Box::new(element)); + let (mut vm, callable) = callable_vm_with_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + named, + ); + let handle = resource_handle(&mut vm); + let valid = Value::Array(vec![named_handle_box(handle, None)].into()); + assert_eq!( + vm.invoke_callable(callable.clone(), &[valid]) + .expect("array of named structs with live resources should pass"), + Value::Null + ); + + let invalid = Value::Array(vec![named_handle_box(41, None)].into()); + let error = vm + .invoke_callable(callable, &[invalid]) + .expect_err("array of named structs must reject invalid nested resources"); + assert_resource_schema_error(error, "invalid_resource_handle"); + } + + #[test] + fn host_named_struct_rejects_nonempty_type_args() { + let (_schema, named) = handle_box_schema(); + let schema = TypeSchema::Named("HandleBox".to_string(), vec![TypeSchema::Int]); + let (mut vm, callable) = callable_vm_with_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + named, + ); + let handle = resource_handle(&mut vm); + let error = vm + .invoke_callable(callable, &[named_handle_box(handle, None)]) + .expect_err("host Named with type args must fail closed"); + match error { + VmError::HostError(message) => { + assert!( + message.contains("unknown named struct"), + "unexpected host error: {message}" + ); + } + other => panic!("expected HostError, got {other:?}"), + } + } + + #[test] + fn unknown_named_struct_fails_closed() { + let schema = TypeSchema::Named("MissingBox".to_string(), Vec::new()); + let (mut vm, callable) = + callable_vm(&[OpCode::Ret as u8], Vec::new(), schema, TypeSchema::Null); + let mut object = VmMap::new(); + object.insert(Value::string("file"), Value::Int(1)); + let error = vm + .invoke_callable(callable, &[Value::Map(object.into())]) + .expect_err("unknown Named must fail closed"); + match error { + VmError::HostError(message) => { + assert!( + message.contains("unknown named struct"), + "unexpected host error: {message}" + ); + } + other => panic!("expected HostError, got {other:?}"), + } + } + + #[test] + fn cyclic_named_struct_does_not_hang_and_classifies_resources() { + let mut fields = HashMap::new(); + fields.insert("file".to_string(), TypeSchema::Resource(resource_key())); + fields.insert( + "child".to_string(), + TypeSchema::Optional(Box::new(TypeSchema::Named("Node".to_string(), Vec::new()))), + ); + let mut named = HashMap::new(); + named.insert("Node".to_string(), TypeSchema::Object(fields)); + let schema = TypeSchema::Named("Node".to_string(), Vec::new()); + let (mut vm, callable) = callable_vm_with_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + named, + ); + let handle = resource_handle(&mut vm); + let mut inner = VmMap::new(); + inner.insert(Value::string("file"), Value::Int(handle)); + let mut outer = VmMap::new(); + outer.insert(Value::string("file"), Value::Int(handle)); + outer.insert(Value::string("child"), Value::Map(inner.into())); + assert_eq!( + vm.invoke_callable(callable.clone(), &[Value::Map(outer.into())]) + .expect("acyclic value of cyclic schema should pass"), + Value::Null + ); + + let mut bad = VmMap::new(); + bad.insert(Value::string("file"), Value::Int(41)); + let error = vm + .invoke_callable(callable, &[Value::Map(bad.into())]) + .expect_err("cyclic named schema must still validate nested resources"); + assert_resource_schema_error(error, "invalid_resource_handle"); + } + + #[test] + fn named_struct_return_validates_nested_resource() { + let (schema, named) = handle_box_schema(); + let (mut vm, callable) = callable_vm_with_named( + &[OpCode::Ldloc as u8, 0, OpCode::Ret as u8], + Vec::new(), + schema.clone(), + schema, + named, + ); + let handle = resource_handle(&mut vm); + let returned = vm + .invoke_callable(callable.clone(), &[named_handle_box(handle, None)]) + .expect("named return with live resource should pass"); + assert!(matches!(returned, Value::Map(_))); + + let error = vm + .invoke_callable(callable, &[named_handle_box(41, None)]) + .expect_err("named return must reject invalid nested resource"); + assert_resource_schema_error(error, "invalid_resource_handle"); + } + + fn point_value(x: i64, y: i64) -> Value { + let mut object = VmMap::new(); + object.insert(Value::string("x"), Value::Int(x)); + object.insert(Value::string("y"), Value::Int(y)); + Value::Map(object.into()) + } + + fn run_compiled(source: &str) -> crate::VmResult> { + let compiled = crate::compile_source(source).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + loop { + match vm.run()? { + VmStatus::Halted => break, + VmStatus::Yielded => continue, + VmStatus::Waiting(_) => panic!("compiled guest named program should not wait"), + } + } + Ok(vm.stack().to_vec()) + } + + #[test] + fn guest_named_struct_validates_callable_argument_without_host_catalog() { + let schema = TypeSchema::Named("Point".to_string(), Vec::new()); + let (mut vm, callable) = callable_vm_with_guest_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + point_guest_decl(), + ); + assert_eq!( + vm.invoke_callable(callable.clone(), &[point_value(1, 2)]) + .expect("guest Named argument should pass without host catalog"), + Value::Null + ); + + let error = vm + .invoke_callable(callable, &[Value::Int(1)]) + .expect_err("guest Named must still require a map"); + assert!( + matches!(error, VmError::TypeMismatch("callable argument schema")), + "unexpected error: {error:?}" + ); + } + + #[test] + fn guest_named_struct_validates_callable_return_without_host_catalog() { + let schema = TypeSchema::Named("Point".to_string(), Vec::new()); + let (mut vm, callable) = callable_vm_with_guest_named( + &[OpCode::Ldloc as u8, 0, OpCode::Ret as u8], + Vec::new(), + schema.clone(), + schema, + point_guest_decl(), + ); + let returned = vm + .invoke_callable(callable, &[point_value(3, 4)]) + .expect("guest Named return should pass without host catalog"); + assert_eq!(returned, point_value(3, 4)); + } + + #[test] + fn guest_named_decls_do_not_accept_unknown_host_named() { + let schema = TypeSchema::Named("MissingBox".to_string(), Vec::new()); + let (mut vm, callable) = callable_vm_with_guest_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + point_guest_decl(), + ); + let error = vm + .invoke_callable(callable, &[point_value(1, 2)]) + .expect_err("unknown host Named must stay fail-closed"); + match error { + VmError::HostError(message) => { + assert!( + message.contains("unknown named struct"), + "unexpected host error: {message}" + ); + } + other => panic!("expected HostError, got {other:?}"), + } + } + + #[test] + fn guest_named_struct_validates_nested_resource_without_host_catalog() { + let mut fields = HashMap::new(); + fields.insert("file".to_string(), TypeSchema::Resource(resource_key())); + let mut guest = HashMap::new(); + guest.insert( + "HandleBox".to_string(), + StructDecl { + name: "HandleBox".to_string(), + type_params: Vec::new(), + body_schema: TypeSchema::Object(fields), + origin: crate::compiler::StructDeclOrigin::Guest, + }, + ); + let schema = TypeSchema::Named("HandleBox".to_string(), Vec::new()); + let (mut vm, callable) = callable_vm_with_guest_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + guest, + ); + let handle = resource_handle(&mut vm); + assert_eq!( + vm.invoke_callable(callable.clone(), &[named_handle_box(handle, None)]) + .expect("guest Named nested resource should pass"), + Value::Null + ); + let error = vm + .invoke_callable(callable, &[named_handle_box(41, None)]) + .expect_err("guest Named must still reject invalid nested resources"); + assert_resource_schema_error(error, "invalid_resource_handle"); + } + + #[test] + fn generic_guest_named_struct_instantiates_body() { + let mut fields = HashMap::new(); + fields.insert( + "value".to_string(), + TypeSchema::GenericParam("T".to_string()), + ); + let mut guest = HashMap::new(); + guest.insert( + "Holder".to_string(), + StructDecl { + name: "Holder".to_string(), + type_params: vec!["T".to_string()], + body_schema: TypeSchema::Object(fields), + origin: crate::compiler::StructDeclOrigin::Guest, + }, + ); + let schema = TypeSchema::Named("Holder".to_string(), vec![TypeSchema::Int]); + let (mut vm, callable) = callable_vm_with_guest_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + guest, + ); + let mut valid = VmMap::new(); + valid.insert(Value::string("value"), Value::Int(7)); + assert_eq!( + vm.invoke_callable(callable.clone(), &[Value::Map(valid.into())]) + .expect("instantiated guest Named should pass"), + Value::Null + ); + + let error = vm + .invoke_callable(callable, &[Value::Int(7)]) + .expect_err("generic guest Named must still require a map"); + assert!( + matches!(error, VmError::TypeMismatch("callable argument schema")), + "unexpected error: {error:?}" + ); + } + + #[test] + fn guest_named_struct_rejects_empty_map_for_required_resource_field() { + let schema = TypeSchema::Named("HandleBox".to_string(), Vec::new()); + let (mut vm, callable) = callable_vm_with_guest_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + guest_handle_box_decl(), + ); + let error = vm + .invoke_callable(callable, &[empty_map()]) + .expect_err("guest Named must reject missing required resource field"); + assert_callable_schema_error(error); + } + + #[test] + fn guest_named_struct_rejects_nested_empty_map() { + let mut inner_fields = HashMap::new(); + inner_fields.insert("file".to_string(), TypeSchema::Resource(resource_key())); + let mut outer_fields = HashMap::new(); + outer_fields.insert( + "inner".to_string(), + TypeSchema::Named("HandleBox".to_string(), Vec::new()), + ); + let mut guest = guest_handle_box_decl(); + guest.insert( + "Wrapper".to_string(), + StructDecl { + name: "Wrapper".to_string(), + type_params: Vec::new(), + body_schema: TypeSchema::Object(outer_fields), + origin: crate::compiler::StructDeclOrigin::Guest, + }, + ); + let schema = TypeSchema::Named("Wrapper".to_string(), Vec::new()); + let (mut vm, callable) = callable_vm_with_guest_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + guest, + ); + let mut nested_empty = VmMap::new(); + nested_empty.insert(Value::string("inner"), empty_map()); + let error = vm + .invoke_callable(callable.clone(), &[Value::Map(nested_empty.into())]) + .expect_err("nested guest Named must reject empty inner object"); + assert_callable_schema_error(error); + + let error = vm + .invoke_callable(callable, &[empty_map()]) + .expect_err("outer guest Named must reject missing required nested field"); + assert_callable_schema_error(error); + } + + #[test] + fn guest_named_struct_optional_field_may_be_omitted() { + let schema = TypeSchema::Named("HandleBox".to_string(), Vec::new()); + let (mut vm, callable) = callable_vm_with_guest_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + guest_handle_box_decl(), + ); + let handle = resource_handle(&mut vm); + assert_eq!( + vm.invoke_callable(callable, &[named_handle_box(handle, None)]) + .expect("optional guest Named field may be omitted"), + Value::Null + ); + } + + #[test] + fn generic_guest_named_struct_resource_substitution_is_load_bearing() { + let schema = TypeSchema::Named( + "Holder".to_string(), + vec![TypeSchema::Resource(resource_key())], + ); + let (mut vm, callable) = callable_vm_with_guest_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + guest_holder_decl(), + ); + let handle = resource_handle(&mut vm); + let mut valid = VmMap::new(); + valid.insert(Value::string("value"), Value::Int(handle)); + assert_eq!( + vm.invoke_callable(callable.clone(), &[Value::Map(valid.into())]) + .expect("Holder with a live handle should pass"), + Value::Null + ); + + let error = vm + .invoke_callable(callable.clone(), &[empty_map()]) + .expect_err("Holder must reject a missing resource field"); + assert_callable_schema_error(error); + + let mut wrong = VmMap::new(); + wrong.insert(Value::string("value"), Value::Int(41)); + let error = vm + .invoke_callable(callable.clone(), &[Value::Map(wrong.into())]) + .expect_err("Holder must reject an invalid handle"); + assert_resource_schema_error(error, "invalid_resource_handle"); + + let other = other_resource_handle(&mut vm); + let mut other_map = VmMap::new(); + other_map.insert(Value::string("value"), Value::Int(other)); + let error = vm + .invoke_callable(callable, &[Value::Map(other_map.into())]) + .expect_err("Holder must reject the wrong resource type"); + assert_resource_schema_error(error, "resource_type_key_mismatch"); + } + + #[test] + fn generic_guest_named_struct_arity_mismatch_fails_closed() { + let schema = TypeSchema::Named("Holder".to_string(), Vec::new()); + let (mut vm, callable) = callable_vm_with_guest_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + guest_holder_decl(), + ); + let mut value = VmMap::new(); + value.insert(Value::string("value"), Value::Int(7)); + let error = vm + .invoke_callable(callable, &[Value::Map(value.into())]) + .expect_err("generic arity mismatch must fail closed"); + match error { + VmError::HostError(message) => { + assert!( + message.contains("unknown named struct"), + "unexpected host error: {message}" + ); + } + other => panic!("expected HostError, got {other:?}"), + } + } + + #[test] + fn generic_guest_named_struct_wrong_arity_vector_fails_closed() { + let schema = + TypeSchema::Named("Holder".to_string(), vec![TypeSchema::Int, TypeSchema::Int]); + let (mut vm, callable) = callable_vm_with_guest_named( + &[OpCode::Ret as u8], + Vec::new(), + schema, + TypeSchema::Null, + guest_holder_decl(), + ); + let mut value = VmMap::new(); + value.insert(Value::string("value"), Value::Int(7)); + let error = vm + .invoke_callable(callable, &[Value::Map(value.into())]) + .expect_err("extra generic args must fail closed"); + match error { + VmError::HostError(message) => { + assert!( + message.contains("unknown named struct"), + "unexpected host error: {message}" + ); + } + other => panic!("expected HostError, got {other:?}"), + } + } + + #[test] + fn vmbc_rejects_duplicate_generic_params_on_named_struct_decode() { + let mut guest = guest_holder_decl(); + guest.get_mut("Holder").expect("Holder").type_params = + vec!["T".to_string(), "T".to_string()]; + let program = + Program::new(Vec::new(), vec![OpCode::Ret as u8]).with_named_struct_decls(guest); + let bytes = encode_program(&program).expect("malformed generic params should still encode"); + decode_program(&bytes).expect_err("duplicate generic params must be rejected"); + } + + #[test] + fn compiled_guest_named_callable_and_return_roundtrip() { + let stack = run_compiled( + r#" + struct Point { x: int, y: int } + fn ident(p: Point) -> Point { p } + ident({ x: 1, y: 2 }); + "#, + ) + .expect("compiled guest Named callable should run"); + assert_eq!(stack.len(), 1); + assert_eq!(stack[0], point_value(1, 2)); + } + + #[test] + fn compiled_generic_guest_named_like_lru_state_runs() { + let stack = run_compiled( + r#" + struct Holder { value: T } + fn ident(p: Holder) -> Holder { p } + ident({ value: 9 }); + "#, + ) + .expect("compiled generic guest Named should run"); + assert_eq!(stack.len(), 1); + let mut expected = VmMap::new(); + expected.insert(Value::string("value"), Value::Int(9)); + assert_eq!(stack[0], Value::Map(expected.into())); + } + + #[test] + fn compiled_nested_guest_named_struct_runs() { + let stack = run_compiled( + r#" + struct Inner { n: int } + struct Outer { inner: Inner } + fn ident(p: Outer) -> Outer { p } + ident({ inner: { n: 4 } }); + "#, + ) + .expect("compiled nested guest Named should run"); + assert_eq!(stack.len(), 1); + assert!(matches!(stack[0], Value::Map(_))); + } + + #[test] + fn vmbc_roundtrip_preserves_guest_named_struct_decls() { + let compiled = crate::compile_source( + r#" + struct Point { x: int, y: int } + fn ident(p: Point) -> Point { p } + ident({ x: 8, y: 9 }); + "#, + ) + .expect("source should compile"); + assert!( + compiled.program.named_struct_decls.contains_key("Point"), + "codegen should attach guest struct decls" + ); + assert!( + compiled + .program + .named_struct_decls + .values() + .all(StructDecl::is_guest), + "codegen guest table must not carry catalog structs" + ); + let bytes = encode_program(&compiled.program).expect("program should encode"); + let decoded = decode_program(&bytes).expect("program should decode"); + assert!( + decoded.named_struct_decls.contains_key("Point"), + "VMBC should preserve guest struct decls" + ); + let mut vm = Vm::new(decoded); + assert_eq!( + vm.run().expect("decoded program should halt"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[point_value(8, 9)]); + } } diff --git a/src/vmbc.rs b/src/vmbc.rs index 687fae55..e58c5172 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -7,18 +7,20 @@ use crate::bytecode::{ CallableKind, CallablePrototype, CallableTarget, CaptureBindingMode, ExportedCallable, FunctionRegion, MAX_FRAME_LOCAL_COUNT, RootCallableBinding, ScriptFunction, TypeMap, ValueType, }; -use crate::compiler::ir::TypeSchema; +use crate::compiler::ir::{StructDecl, StructDeclOrigin, TypeSchema}; use crate::debug_info::{ArgInfo, DebugFunction, DebugInfo, LineInfo, LocalInfo}; use crate::host_api::{ - HostApiFingerprint, HostImportParam, HostImportSchema, HostParamPassing, HostTypeSchema, - MAX_HOST_CATALOG_PARAMETERS, MAX_HOST_FUNCTION_NAME_LEN, MAX_HOST_RESOURCE_KEY_LEN, - MAX_HOST_SCHEMA_DEPTH, MAX_HOST_SCHEMA_NODES, MAX_HOST_SCHEMA_PROPERTIES, ResourceTypeKey, + HostApiFingerprint, HostImportParam, HostImportSchema, HostParamPassing, HostStructField, + HostTypeSchema, MAX_HOST_CATALOG_PARAMETERS, MAX_HOST_FUNCTION_NAME_LEN, + MAX_HOST_PARAMETER_NAME_LEN, MAX_HOST_RESOURCE_KEY_LEN, MAX_HOST_SCHEMA_DEPTH, + MAX_HOST_SCHEMA_NODES, MAX_HOST_SCHEMA_PROPERTIES, ResourceTypeKey, }; use crate::vm::{HostImport, OpCode, Program, Value}; const MAGIC: [u8; 4] = *b"VMBC"; const VERSION_V11: u16 = 11; const VERSION_V12: u16 = 12; +const VERSION_V13: u16 = 13; const FLAGS: u16 = 0; const MAX_WIRE_PAYLOAD_BYTES: usize = 64 * 1024 * 1024; const MAX_WIRE_BLOB_BYTES: usize = 16 * 1024 * 1024; @@ -304,7 +306,7 @@ fn read_constant(cursor: &mut Cursor<'_>, depth: usize) -> Result Result, WireError> { let mut out = Vec::new(); out.extend_from_slice(&MAGIC); - out.extend_from_slice(&VERSION_V12.to_le_bytes()); + out.extend_from_slice(&VERSION_V13.to_le_bytes()); out.extend_from_slice(&FLAGS.to_le_bytes()); write_u32_count("constants", program.constants.len(), &mut out)?; @@ -341,6 +343,7 @@ pub fn encode_program(program: &Program) -> Result, WireError> { write_type_map(&mut out, program.type_map.as_ref())?; write_debug_info(&mut out, program.debug.as_ref())?; write_callable_metadata(&mut out, program)?; + write_named_struct_decls(&mut out, program)?; Ok(out) } @@ -359,7 +362,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { let version = cursor.read_u16()?; let has_host_import_schemas = match version { VERSION_V11 => false, - VERSION_V12 => true, + VERSION_V12 | VERSION_V13 => true, _ => return Err(WireError::UnsupportedVersion(version)), }; @@ -425,6 +428,11 @@ pub fn decode_program(bytes: &[u8]) -> Result { root_callable_bindings, exported_callables, ) = read_callable_metadata(&mut cursor)?; + let named_struct_decls = if version >= VERSION_V13 { + read_named_struct_decls(&mut cursor)? + } else { + HashMap::new() + }; if !cursor.is_eof() { return Err(WireError::TrailingBytes); @@ -438,6 +446,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { program.function_regions = function_regions; program.root_callable_bindings = root_callable_bindings; program.exported_callables = exported_callables; + program.named_struct_decls = named_struct_decls; let type_map_local_count = program .type_map .as_ref() @@ -1063,6 +1072,58 @@ fn write_callable_metadata(out: &mut Vec, program: &Program) -> Result<(), W Ok(()) } +fn write_named_struct_decls(out: &mut Vec, program: &Program) -> Result<(), WireError> { + let mut decls = program.named_struct_decls.values().collect::>(); + decls.sort_unstable_by(|lhs, rhs| lhs.name.cmp(&rhs.name)); + write_u32_count("named struct decls", decls.len(), out)?; + for decl in decls { + write_string("named struct name", &decl.name, out)?; + write_u32_count("named struct type params", decl.type_params.len(), out)?; + for type_param in &decl.type_params { + write_string("named struct type param", type_param, out)?; + } + write_schema(&decl.body_schema, out)?; + } + Ok(()) +} + +fn read_named_struct_decls( + cursor: &mut Cursor<'_>, +) -> Result, WireError> { + let count = cursor.read_count("named struct decls", 1)?; + let mut decls = HashMap::new(); + reserve_map(&mut decls, "named struct decls", count)?; + for _ in 0..count { + let name = cursor.read_string()?; + let param_count = cursor.read_count("named struct type params", 1)?; + let mut type_params = Vec::new(); + reserve_vec(&mut type_params, "named struct type params", param_count)?; + for _ in 0..param_count { + let param = cursor.read_string()?; + if type_params.iter().any(|existing| existing == ¶m) { + return Err(WireError::InvalidValueType(0)); + } + type_params.push(param); + } + let body_schema = read_schema(cursor, 0)?; + if decls + .insert( + name.clone(), + StructDecl { + name, + type_params, + body_schema, + origin: StructDeclOrigin::Guest, + }, + ) + .is_some() + { + return Err(WireError::InvalidValueType(0)); + } + } + Ok(decls) +} + fn write_u16_list(field: &'static str, values: &[u16], out: &mut Vec) -> Result<(), WireError> { write_u32_count(field, values.len(), out)?; for value in values { @@ -1622,6 +1683,15 @@ fn write_host_type_schema( out.push(12); write_string("host resource type key", key.as_str(), out)?; } + HostTypeSchema::Named { name, fields } => { + out.push(13); + write_string("host named struct name", name, out)?; + write_u32_count("host named struct fields", fields.len(), out)?; + for field in fields { + write_string("host named struct field name", &field.name, out)?; + write_host_type_schema(&field.ty, out, next_host_schema_depth(depth)?)?; + } + } } Ok(()) } @@ -1685,6 +1755,26 @@ fn read_host_type_schema( .map_err(|_| WireError::InvalidHostResourceKey)?; Ok(HostTypeSchema::Resource(key)) } + 13 => { + let name = + cursor.read_bounded_string("host named struct name", MAX_HOST_FUNCTION_NAME_LEN)?; + let count = cursor.read_count_with_overhead("host named struct fields", 1, 1)?; + if count > MAX_HOST_SCHEMA_PROPERTIES { + return Err(WireError::LengthTooLarge("host named struct fields", count)); + } + cursor.debit_host_schema_properties(count)?; + let mut fields = Vec::new(); + reserve_vec(&mut fields, "host named struct fields", count)?; + for _ in 0..count { + let field_name = cursor.read_bounded_string( + "host named struct field name", + MAX_HOST_PARAMETER_NAME_LEN, + )?; + let ty = read_host_type_schema(cursor, next_host_schema_depth(depth)?)?; + fields.push(HostStructField::new(field_name, ty)); + } + Ok(HostTypeSchema::Named { name, fields }) + } other => Err(WireError::InvalidHostSchemaTag(other)), } } diff --git a/tests/builtins/io_async_tests.rs b/tests/builtins/io_async_tests.rs index 4f25b473..e1a7ba59 100644 --- a/tests/builtins/io_async_tests.rs +++ b/tests/builtins/io_async_tests.rs @@ -2,6 +2,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use vm::{Value, Vm, VmError, VmStatus, compile_source}; +use super::vm_reset::reset_for_reuse_to_ready; + fn run_source(source: &str) -> Result, VmError> { let compiled = compile_source(&format!("use io;\n{source}")).expect("async io source should compile"); @@ -236,10 +238,13 @@ fn async_io_reset_kills_and_reaps_the_entire_popen_process_group() { descendant: Some(descendant_pid), }; - let _ = vm.reset_for_reuse(); + tokio::runtime::Runtime::new() + .expect("reset runtime should build") + .block_on(async { + reset_for_reuse_to_ready(&mut vm).expect("reset should reach quiescence"); + }); assert!(vm.execution_scope().resources().is_empty()); assert!(vm.execution_scope().operations().is_empty()); - std::thread::sleep(std::time::Duration::from_millis(1_200)); assert!( !marker_path.exists(), "a killed process group must not run descendants" diff --git a/tests/builtins/io_scope_lifecycle_tests.rs b/tests/builtins/io_scope_lifecycle_tests.rs index 739a5ec7..78ce8260 100644 --- a/tests/builtins/io_scope_lifecycle_tests.rs +++ b/tests/builtins/io_scope_lifecycle_tests.rs @@ -11,6 +11,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; use vm::operation::OperationCancelReason; use vm::operation::OperationId; @@ -18,6 +19,8 @@ use vm::resource::close::{CloseProgress, HostResource}; use vm::resource::{ResourceCloseReason, ResourceResult}; use vm::{Value, Vm, VmError, VmStatus, compile_source}; +use super::vm_reset::reset_for_reuse_to_ready; + /// Helper: run an IO source to completion, returning the final stack. fn run_source(source: &str) -> Result, VmError> { let wrapped = format!("use io;\n{source}"); @@ -368,7 +371,7 @@ fn reset_for_reuse_joins_pending_io_worker() { )); assert_eq!(vm.execution_scope().operations().len(), 1); - let _ = vm.reset_for_reuse(); + reset_for_reuse_to_ready(&mut vm).expect("reset should reach quiescence"); assert!(vm.execution_scope().operations().is_empty()); assert!(vm.execution_scope().resources().is_empty()); } @@ -381,7 +384,7 @@ fn reset_for_reuse_retires_io_resources_through_scope() { "open leaves a live IO resource in the scope" ); - let _ = vm.reset_for_reuse(); + reset_for_reuse_to_ready(&mut vm).expect("reset should reach quiescence"); assert!( vm.execution_scope().resources().is_empty() && vm.execution_scope().operations().is_empty(), @@ -434,7 +437,7 @@ fn reset_for_reuse_terminates_live_popen_process_tree() { marker: marker.clone(), }; - let _ = vm.reset_for_reuse(); + reset_for_reuse_to_ready(&mut vm).expect("reset should reach quiescence"); assert!(vm.execution_scope().resources().is_empty()); wait_for_process_exit(descendant); let _ = std::fs::remove_file(marker); diff --git a/tests/builtins/sqlite_scope_lifecycle_tests.rs b/tests/builtins/sqlite_scope_lifecycle_tests.rs index 8c8a395c..f8c7ced7 100644 --- a/tests/builtins/sqlite_scope_lifecycle_tests.rs +++ b/tests/builtins/sqlite_scope_lifecycle_tests.rs @@ -13,14 +13,29 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; -use vm::{SqliteHostExt, Vm, VmError, VmStatus, compile_source}; +use vm::{ + CompileSourceFileOptions, HostFunctionRegistry, SqliteHostExt, Vm, VmError, VmStatus, + compile_source, compile_source_with_flavor_and_options, + register_sqlite_builtin_module_from_catalog, sqlite_host_catalog, +}; + +use super::vm_reset::reset_for_reuse_to_ready; /// Helper: run a SQLite source to completion. Scripts use `assert(...)` for /// value checks; a failed assert surfaces as a host error. fn run_sqlite_source(policy: vm::SqlitePolicy, source: &str) -> Result<(), VmError> { let wrapped = format!("use sqlite;\n{source}"); - let compiled = compile_source(&wrapped).expect("source should compile"); - let mut vm = Vm::new(compiled.program); + let catalog = sqlite_host_catalog(); + let compiled = compile_source_with_flavor_and_options( + &wrapped, + vm::compiler::SourceFlavor::RustScript, + CompileSourceFileOptions::default().with_host_api_catalog(catalog.clone()), + ) + .expect("source should compile"); + let mut vm = Vm::try_new(compiled.program)?; + let mut registry = HostFunctionRegistry::empty(); + register_sqlite_builtin_module_from_catalog(&mut registry, catalog.as_ref())?; + registry.bind_vm_cached(&mut vm)?; vm.configure_sqlite(policy); let mut status = vm.run()?; @@ -38,6 +53,22 @@ fn run_sqlite_source(policy: vm::SqlitePolicy, source: &str) -> Result<(), VmErr } } +/// Helper: run a legacy builtin SQLite source expecting a host error. This +/// keeps handle-validation coverage on the builtin dispatch path, which does +/// not use catalog resource passing. +fn run_sqlite_builtin_host_error(policy: vm::SqlitePolicy, source: &str) -> String { + let wrapped = format!("use sqlite;\n{source}"); + let compiled = compile_source(&wrapped).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_sqlite(policy); + match vm.run() { + Ok(VmStatus::Halted) => panic!("expected host error, got success"), + Ok(other) => panic!("expected host error, got status: {other:?}"), + Err(VmError::HostError(message)) => message, + Err(other) => format!("{other:?}"), + } +} + /// Helper: run a SQLite source expecting a host error, returning its message. fn run_sqlite_host_error(policy: vm::SqlitePolicy, source: &str) -> String { match run_sqlite_source(policy, source) { @@ -76,22 +107,79 @@ fn sqlite_round_trip_supports_typed_values_and_ordered_transactions() { r#" use bytes; let db = sqlite::open({ path: "state.db", mode: "read_write_create", limits: { max_rows: 128, max_result_bytes: 65536, max_statements: 16, max_transaction_ms: 5000 } }); - sqlite::execute(db, "CREATE TABLE values_table (id INTEGER PRIMARY KEY, n INTEGER, r REAL, s TEXT, b BLOB, z TEXT)", []); + sqlite::execute(&db, "CREATE TABLE values_table (id INTEGER PRIMARY KEY, n INTEGER, r REAL, s TEXT, b BLOB, z TEXT)", []); let blob_payload = bytes::from_hex("000102"); - let ins = sqlite::execute(db, "INSERT INTO values_table (n, r, s, b, z) VALUES (?1, ?2, ?3, ?4, ?5)", {7, 1.5, "hello", blob_payload, null}); - assert(ins["rows_affected"] == 1); - let rowset = sqlite::query(db, "SELECT n, r, s, b, z FROM values_table ORDER BY id", [], { max_rows: 8, max_result_bytes: 65536 }); - assert(rowset["truncated"] == false); - assert(rowset["columns"] == {"n", "r", "s", "b", "z"}); - assert(rowset["rows"] == { {7, 1.5, "hello", blob_payload, null} }); - - let results = sqlite::transaction(db, { - { sql: "INSERT INTO values_table (n) VALUES (?1)", params: {8} }, - { sql: "INSERT INTO values_table (n) VALUES (?1)", params: {9} } + let ins = sqlite::execute(&db, "INSERT INTO values_table (n, r, s, b, z) VALUES (?1, ?2, ?3, ?4, ?5)", [ + { kind: "int", int_value: 7, float_value: null, text_value: null, blob_value: null }, + { kind: "float", int_value: null, float_value: 1.5, text_value: null, blob_value: null }, + { kind: "text", int_value: null, float_value: null, text_value: "hello", blob_value: null }, + { kind: "blob", int_value: null, float_value: null, text_value: null, blob_value: blob_payload }, + { kind: "null", int_value: null, float_value: null, text_value: null, blob_value: null } + ]); + let affected = ins.rows_affected; + assert(affected == 1); + let rowset = sqlite::query(&db, "SELECT n, r, s, b, z FROM values_table ORDER BY id", [], { max_rows: 8, max_result_bytes: 65536 }); + let truncated = rowset.truncated; + let next_cursor = rowset.next_cursor; + assert(truncated == false); + assert(next_cursor == 7); + let columns = rowset.columns; + assert(columns == {"n", "r", "s", "b", "z"}); + let row = rowset.rows[0]; + let cells = row.cells; + let int_cell = cells[0]; + let int_kind = int_cell.kind; + let int_value = int_cell.int_value; + assert(int_kind == "int"); + assert(int_value == 7); + let float_cell = cells[1]; + let float_kind = float_cell.kind; + let float_value = float_cell.float_value; + assert(float_kind == "float"); + assert(float_value == 1.5); + let text_cell = cells[2]; + let text_kind = text_cell.kind; + let text_value = text_cell.text_value; + assert(text_kind == "text"); + assert(text_value == "hello"); + let blob_cell = cells[3]; + let blob_kind = blob_cell.kind; + let blob_value = blob_cell.blob_value; + assert(blob_kind == "blob"); + assert(blob_value == blob_payload); + let null_cell = cells[4]; + let null_kind = null_cell.kind; + let null_int = null_cell.int_value; + assert(null_kind == "null"); + assert(null_int == null); + + let results = sqlite::transaction(&db, { + { sql: "INSERT INTO values_table (n) VALUES (?1)", params: [{ kind: "int", int_value: 8 }] }, + { sql: "INSERT INTO values_table (n) VALUES (?1)", params: [{ kind: "int", int_value: 9 }] }, + { sql: "SELECT n FROM values_table ORDER BY id", query: true, limits: { max_rows: 8, max_result_bytes: 65536 } } }); assert(type(results) == "array"); - let count = sqlite::query(db, "SELECT count(*) AS count FROM values_table", [], { max_rows: 8, max_result_bytes: 65536 }); - assert(count["rows"] == { {3} }); + let first_result = results[0]; + let first_kind = first_result.kind; + assert(first_kind == "execute"); + assert(first_result.execute.rows_affected == 1); + assert(first_result.query == null); + let second_result = results[1]; + let second_kind = second_result.kind; + assert(second_kind == "execute"); + let third_result = results[2]; + let third_kind = third_result.kind; + assert(third_kind == "query"); + assert(third_result.execute == null); + assert(third_result.query.rows[0].cells[0].int_value == 7); + let count = sqlite::query(&db, "SELECT count(*) AS count FROM values_table", [], { max_rows: 8, max_result_bytes: 65536 }); + let count_row = count.rows[0]; + let count_cells = count_row.cells; + let count_cell = count_cells[0]; + let count_kind = count_cell.kind; + let count_value = count_cell.int_value; + assert(count_kind == "int"); + assert(count_value == 3); sqlite::close(db); "#, ) @@ -99,6 +187,97 @@ fn sqlite_round_trip_supports_typed_values_and_ordered_transactions() { fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); } +#[test] +fn sqlite_value_discriminators_reject_unknown_missing_and_multiple_payloads() { + let root = temporary_root("value-discriminators"); + let policy = policy_for(&root); + for (value, expected) in [ + (r#"{ kind: "unknown" }"#, "unknown SQLite value kind"), + (r#"{ kind: "int" }"#, "missing SQLite int_value"), + ( + r#"{ kind: "int", int_value: 1, text_value: "extra" }"#, + "multiple non-null payloads", + ), + ] { + let source = format!( + r#"let db = sqlite::open({{ path: "state.db", mode: "read_write_create", limits: {{}} }}); + sqlite::execute(&db, "SELECT ?", [{value}]);"# + ); + let error = run_sqlite_host_error(policy.clone(), &source); + assert!( + error.contains(expected), + "expected {expected:?} for {value}, got {error}" + ); + } + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn sqlite_value_mismatched_payload_and_limits_are_rejected() { + let mismatch_root = temporary_root("value-mismatch"); + let mismatch = run_sqlite_builtin_host_error( + policy_for(&mismatch_root), + r#" + let db = sqlite::open({ path: "state.db", mode: "read_write_create", limits: {} }); + sqlite::execute(db, "SELECT ?", [{ kind: "int", int_value: "wrong" }]); + "#, + ); + assert!( + mismatch.contains("SQLite int_value payload"), + "mismatched payload type must be rejected, got: {mismatch}" + ); + fs::remove_dir_all(mismatch_root).expect("temporary mismatch root should be removed"); + + let count_root = temporary_root("parameter-count-limit"); + let count = run_sqlite_host_error( + policy_for(&count_root), + r#" + let db = sqlite::open({ path: "state.db", mode: "read_write_create", limits: { max_parameters: 1 } }); + sqlite::execute(&db, "SELECT ?1, ?2", [{ kind: "int", int_value: 1 }, { kind: "int", int_value: 2 }]); + "#, + ); + assert!( + count.contains("parameter count") && count.contains("configured limit"), + "parameter count limit must be enforced, got: {count}" + ); + fs::remove_dir_all(count_root).expect("temporary count root should be removed"); + + let bytes_root = temporary_root("parameter-bytes-limit"); + let bytes = run_sqlite_host_error( + policy_for(&bytes_root), + r#" + let db = sqlite::open({ path: "state.db", mode: "read_write_create", limits: { max_parameter_bytes: 2 } }); + sqlite::execute(&db, "SELECT ?1", [{ kind: "text", text_value: "abc" }]); + "#, + ); + assert!( + bytes.contains("parameters exceed") && bytes.contains("2 byte limit"), + "parameter byte limit must be enforced, got: {bytes}" + ); + fs::remove_dir_all(bytes_root).expect("temporary bytes root should be removed"); +} + +#[test] +fn sqlite_invalid_utf8_text_is_returned_as_blob_value() { + let root = temporary_root("invalid-utf8"); + let policy = policy_for(&root); + run_sqlite_source( + policy, + r#" + use bytes; + let db = sqlite::open({ path: "state.db", mode: "read_write_create", limits: {} }); + let payload = bytes::from_hex("80ff"); + let result = sqlite::query(&db, "SELECT CAST(?1 AS TEXT)", [{ kind: "blob", blob_value: payload }], {}); + let cell = result.rows[0].cells[0]; + assert(cell.kind == "blob"); + assert(cell.blob_value == payload); + sqlite::close(db); + "#, + ) + .expect("invalid UTF-8 SQLite TEXT should use the blob variant"); + fs::remove_dir_all(root).expect("temporary invalid UTF-8 root should be removed"); +} + #[test] fn sqlite_enforces_read_only_vm_local_ids_and_sql_safety() { let root = temporary_root("policy"); @@ -108,7 +287,7 @@ fn sqlite_enforces_read_only_vm_local_ids_and_sql_safety() { policy.clone(), r#" let db = sqlite::open({ path: "state.db", mode: "read_write_create", limits: {} }); - sqlite::execute(db, "CREATE TABLE items (value INTEGER)", []); + sqlite::execute(&db, "CREATE TABLE items (value INTEGER)", []); "#, ) .expect("writer should create the table"); @@ -117,7 +296,7 @@ fn sqlite_enforces_read_only_vm_local_ids_and_sql_safety() { policy.clone(), r#" let db = sqlite::open({ path: "state.db", mode: "read_only", limits: {} }); - sqlite::execute(db, "INSERT INTO items (value) VALUES (1)", []); + sqlite::execute(&db, "INSERT INTO items (value) VALUES (1)", []); "#, ); assert!( @@ -136,7 +315,7 @@ fn sqlite_enforces_read_only_vm_local_ids_and_sql_safety() { let err = run_sqlite_host_error( policy.clone(), &format!( - "let db = sqlite::open({{ path: \"state.db\", mode: \"read_write_create\", limits: {{}} }});\n sqlite::execute(db, \"{bad}\", []);" + "let db = sqlite::open({{ path: \"state.db\", mode: \"read_write_create\", limits: {{}} }});\n sqlite::execute(&db, \"{bad}\", []);" ), ); assert!( @@ -148,7 +327,8 @@ fn sqlite_enforces_read_only_vm_local_ids_and_sql_safety() { } // A SQLite id from another VM must be rejected (foreign arena). - let other_err = run_sqlite_host_error(policy, "sqlite::execute(1234567, \"SELECT 1\", []);"); + let other_err = + run_sqlite_builtin_host_error(policy, "sqlite::execute(1234567, \"SELECT 1\", []);"); assert!( other_err.contains("unknown SQLite database") || other_err.contains("invalid sqlite handle"), @@ -166,17 +346,25 @@ fn sqlite_query_reports_row_and_result_byte_truncation() { policy, r#" let db = sqlite::open({ path: "state.db", mode: "read_write_create", limits: { max_rows: 32, max_result_bytes: 32 } }); - sqlite::execute(db, "CREATE TABLE items (value TEXT)", []); - sqlite::execute(db, "INSERT INTO items (value) VALUES (?1)", {"one"}); - sqlite::execute(db, "INSERT INTO items (value) VALUES (?1)", {"two"}); - sqlite::execute(db, "INSERT INTO items (value) VALUES (?1)", {"three"}); + sqlite::execute(&db, "CREATE TABLE items (value TEXT)", []); + sqlite::execute(&db, "INSERT INTO items (value) VALUES (?1)", [{ kind: "text", text_value: "one" }]); + sqlite::execute(&db, "INSERT INTO items (value) VALUES (?1)", [{ kind: "text", text_value: "two" }]); + sqlite::execute(&db, "INSERT INTO items (value) VALUES (?1)", [{ kind: "text", text_value: "three" }]); - let row_limited = sqlite::query(db, "SELECT value FROM items ORDER BY rowid", [], { max_rows: 1, max_result_bytes: 65536 }); - assert(row_limited["truncated"] == true); - assert(row_limited["rows"] == { {"one"} }); + let row_limited = sqlite::query(&db, "SELECT value FROM items ORDER BY rowid", [], { max_rows: 1, max_result_bytes: 65536 }); + let row_limited_truncated = row_limited.truncated; + assert(row_limited_truncated == true); + let row_limited_row = row_limited.rows[0]; + let row_limited_cells = row_limited_row.cells; + let row_limited_cell = row_limited_cells[0]; + let row_limited_kind = row_limited_cell.kind; + let row_limited_value = row_limited_cell.text_value; + assert(row_limited_kind == "text"); + assert(row_limited_value == "one"); - let byte_limited = sqlite::query(db, "SELECT value FROM items ORDER BY rowid", [], { max_rows: 32, max_result_bytes: 8 }); - assert(byte_limited["truncated"] == true); + let byte_limited = sqlite::query(&db, "SELECT value FROM items ORDER BY rowid", [], { max_rows: 32, max_result_bytes: 8 }); + let byte_limited_truncated = byte_limited.truncated; + assert(byte_limited_truncated == true); "#, ) .expect("truncation should be reported"); @@ -188,7 +376,7 @@ fn sqlite_uses_typed_generation_checked_resource_handles() { let root = temporary_root("handles"); let policy = policy_for(&root); - let err = run_sqlite_host_error( + let err = run_sqlite_builtin_host_error( policy, r#" let a = sqlite::open({ path: "handles.db", mode: "read_write_create", limits: {} }); @@ -234,7 +422,7 @@ fn sqlite_configure_and_clear_own_the_policy() { policy.clone(), r#" let db = sqlite::open({ path: "state.db", mode: "read_write_create", limits: {} }); - sqlite::execute(db, "CREATE TABLE items (value INTEGER)", []); + sqlite::execute(&db, "CREATE TABLE items (value INTEGER)", []); "#, ) .expect("configured policy should allow file opens"); @@ -272,12 +460,18 @@ fn sqlite_close_cancels_siblings_and_reset_retires_all() { policy.clone(), r#" let db = sqlite::open({ path: "state.db", mode: "read_write_create", limits: { max_transaction_ms: 10000, max_result_bytes: 65536 } }); - sqlite::execute(db, "CREATE TABLE items (value INTEGER)", []); - let pending = sqlite::query(db, "WITH RECURSIVE numbers(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM numbers LIMIT 2000000) SELECT sum(value) FROM numbers", [], { max_rows: 1, max_result_bytes: 65536 }); + sqlite::execute(&db, "CREATE TABLE items (value INTEGER)", []); + let pending = sqlite::query(&db, "WITH RECURSIVE numbers(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM numbers LIMIT 2000000) SELECT sum(value) FROM numbers", [], { max_rows: 1, max_result_bytes: 65536 }); sqlite::close(db); let db2 = sqlite::open({ path: "state.db", mode: "read_write_create", limits: { max_transaction_ms: 10000, max_result_bytes: 65536 } }); - let count = sqlite::query(db2, "SELECT count(*) AS count FROM items", [], {}); - assert(count["rows"] == { {0} }); + let count = sqlite::query(&db2, "SELECT count(*) AS count FROM items", [], {}); + let count_row = count.rows[0]; + let count_cells = count_row.cells; + let count_cell = count_cells[0]; + let count_kind = count_cell.kind; + let count_value = count_cell.int_value; + assert(count_kind == "int"); + assert(count_value == 0); sqlite::close(db2); "#, ) @@ -286,7 +480,7 @@ fn sqlite_close_cancels_siblings_and_reset_retires_all() { // VM reset retires all pending sqlite operations and closes every open // connection through the generic scope lifecycle. let compiled = compile_source( - "use sqlite;\nlet db = sqlite::open({ path: \"state.db\", mode: \"read_write_create\", limits: { max_transaction_ms: 10000, max_result_bytes: 65536 } });\nlet pending = sqlite::query(db, \"WITH RECURSIVE numbers(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM numbers LIMIT 2000000) SELECT sum(value) FROM numbers\", [], { max_rows: 1, max_result_bytes: 65536 });", + "use sqlite;\nlet db = sqlite::open({ path: \"state.db\", mode: \"read_write_create\", limits: { max_transaction_ms: 10000, max_result_bytes: 65536 } });\nlet pending = sqlite::query(&db, \"WITH RECURSIVE numbers(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM numbers LIMIT 2000000) SELECT sum(value) FROM numbers\", [], { max_rows: 1, max_result_bytes: 65536 });", ) .expect("reset source should compile"); let mut vm = Vm::new(compiled.program); @@ -298,7 +492,7 @@ fn sqlite_close_cancels_siblings_and_reset_retires_all() { matches!(status, VmStatus::Waiting(_)), "long query should leave the VM waiting, got: {status:?}" ); - let _ = vm.reset_for_reuse(); + reset_for_reuse_to_ready(&mut vm).expect("reset should reach quiescence"); assert!( vm.execution_scope().operations().is_empty(), "reset must retire all pending sqlite operations" @@ -322,17 +516,60 @@ fn sqlite_pending_operation_slots_are_reclaimed_after_completion() { policy, r#" let db = sqlite::open({ path: "state.db", mode: "read_write_create", limits: { max_pending_operations: 4 } }); - sqlite::execute(db, "CREATE TABLE items (value INTEGER)", []); + sqlite::execute(&db, "CREATE TABLE items (value INTEGER)", []); let mut i = 0; while i < 10 { - sqlite::execute(db, "INSERT INTO items (value) VALUES (?1)", {i}); + sqlite::execute(&db, "INSERT INTO items (value) VALUES (?1)", [{ kind: "int", int_value: i }]); i = i + 1; } - let count = sqlite::query(db, "SELECT count(*) AS count FROM items", [], {}); - assert(count["rows"] == { {10} }); + let count = sqlite::query(&db, "SELECT count(*) AS count FROM items", [], {}); + let count_row = count.rows[0]; + let count_cells = count_row.cells; + let count_cell = count_cells[0]; + let count_kind = count_cell.kind; + let count_value = count_cell.int_value; + assert(count_kind == "int"); + assert(count_value == 10); sqlite::close(db); "#, ) .expect("sequential operations beyond the pending limit should succeed after reclaim"); fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); } + +#[test] +fn sqlite_pending_reset_repeatedly_drains_workers_and_keeps_vm_reusable() { + let root = temporary_root("reset-stress"); + let policy = policy_for(&root); + let compiled = compile_source( + "use sqlite;\nlet db = sqlite::open({ path: \"state.db\", mode: \"read_write_create\", limits: { max_transaction_ms: 10000, max_result_bytes: 65536 } });\nlet pending = sqlite::query(&db, \"WITH RECURSIVE numbers(value) AS (SELECT 1 UNION ALL SELECT value + 1 FROM numbers LIMIT 2000000) SELECT sum(value) FROM numbers\", [], { max_rows: 1, max_result_bytes: 65536 });", + ) + .expect("stress source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_sqlite(policy); + + for iteration in 0..32 { + assert!( + matches!( + vm.run().expect("stress run should start"), + VmStatus::Waiting(_) + ), + "iteration {iteration} should leave the SQLite query pending" + ); + reset_for_reuse_to_ready(&mut vm).expect("stress reset should reach quiescence"); + assert!( + vm.execution_scope().operations().is_empty(), + "iteration {iteration} leaked an SQLite operation" + ); + assert!( + vm.execution_scope().resources().is_empty(), + "iteration {iteration} leaked an SQLite resource" + ); + assert!( + vm.is_reusable(), + "iteration {iteration} left VM non-reusable" + ); + } + + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} diff --git a/tests/builtins_tests.rs b/tests/builtins_tests.rs index 44eade8d..f605eb63 100644 --- a/tests/builtins_tests.rs +++ b/tests/builtins_tests.rs @@ -1,5 +1,8 @@ #![cfg(feature = "runtime")] +#[path = "support/vm_reset.rs"] +mod vm_reset; + #[cfg(feature = "async")] #[path = "support/async_test_bridge.rs"] mod async_test_bridge; diff --git a/tests/catalog_free_named_struct_tests.rs b/tests/catalog_free_named_struct_tests.rs new file mode 100644 index 00000000..b7a3b74d --- /dev/null +++ b/tests/catalog_free_named_struct_tests.rs @@ -0,0 +1,276 @@ +//! Catalog-free parser fallback must not reserve the full standard catalog. +//! +//! Public [`parse_source_with_dialect`], import scan, and parses without a +//! catalog snapshot install HTTP named structs only when the HTTP surface is +//! available. Unrelated names such as `JitConfig` and `SqliteLimits` stay +//! unknown unless an explicit catalog provides them. + +use std::sync::Arc; + +use vm::{ + CompileSourceFileOptions, FrontendIr, HostApiBuilder, HostApiCatalog, HostFunctionSchema, + HostStructField, HostStructSchema, HostTypeSchema, ParseError, ParserDialect, + SharedParserOptions, SourceFlavor, compile_source_with_flavor_and_options, + parse_source_with_dialect, +}; + +#[cfg(not(all(feature = "http-client", not(target_family = "wasm"))))] +use vm::{compile_source, compile_source_for_repl}; + +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +use std::path::PathBuf; +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +use vm::{compile_source_file, compile_source_for_repl}; + +const UNRELATED_STANDARD_STRUCTS: [&str; 2] = ["JitConfig", "SqliteLimits"]; +const HTTP_NAMED_STRUCTS: [&str; 5] = [ + "HttpRequest", + "HttpResponse", + "SseRequest", + "SseCallbackAction", + "SseSummary", +]; + +struct CatalogFreeDialect; +impl ParserDialect for CatalogFreeDialect {} +static CATALOG_FREE_DIALECT: CatalogFreeDialect = CatalogFreeDialect; + +fn catalog_free_options() -> SharedParserOptions { + SharedParserOptions { + source_id: 0, + allow_implicit_externs: false, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: false, + } +} + +fn catalog_free_parse(source: &str) -> Result { + parse_source_with_dialect(source, &CATALOG_FREE_DIALECT, catalog_free_options()) +} + +fn catalog_free_import_scan(source: &str) -> Result { + parse_source_with_dialect( + source, + &CATALOG_FREE_DIALECT, + SharedParserOptions { + import_scan_mode: true, + allow_implicit_externs: true, + ..catalog_free_options() + }, + ) +} + +fn assert_unrelated_standard_structs_absent(ir: &FrontendIr) { + assert!( + ir.host_api_metadata.is_none(), + "catalog-free parse must not attach host catalog metadata" + ); + for name in UNRELATED_STANDARD_STRUCTS { + assert!( + !ir.struct_schemas.contains_key(name), + "catalog-free parse must not reserve {name} without host functions" + ); + } +} + +fn widget_catalog() -> Arc { + let widget = HostStructSchema::new( + "Widget", + vec![HostStructField::new("label", HostTypeSchema::String)], + ); + let mut builder = HostApiBuilder::new(); + builder.named_struct(widget.clone()); + builder.function(HostFunctionSchema::with_return( + "widget::origin", + vec![], + widget.as_type(), + )); + Arc::new(builder.build().expect("widget catalog must build")) +} + +/// Panic-safe unique `.rss` file under `std::env::temp_dir()`. +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +struct TempRssPath { + path: PathBuf, +} + +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +impl TempRssPath { + fn new(name: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "{name}_{}_{}.rss", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + )); + Self { path } + } +} + +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +impl Drop for TempRssPath { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +#[test] +fn catalog_free_dialect_parse_does_not_reserve_unrelated_standard_structs() { + let ir = catalog_free_parse("1;").expect("trivial source must parse"); + assert_unrelated_standard_structs_absent(&ir); +} + +#[test] +fn catalog_free_import_scan_does_not_reserve_unrelated_standard_structs() { + let ir = catalog_free_import_scan("use widget;\n1;\n").expect("import scan must parse"); + assert_unrelated_standard_structs_absent(&ir); +} + +#[test] +fn catalog_free_parse_rejects_jit_config_type() { + let error = catalog_free_parse("fn go() -> JitConfig { 1 }") + .expect_err("JitConfig must be unknown without a catalog"); + assert!( + error.message.contains("unknown struct schema 'JitConfig'"), + "catalog-free parse must reject JitConfig as unknown, got {}", + error.message + ); +} + +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +#[test] +fn catalog_free_http_parse_installs_sse_named_structs() { + let ir = catalog_free_parse("1;").expect("trivial source must parse"); + assert_unrelated_standard_structs_absent(&ir); + for name in HTTP_NAMED_STRUCTS { + assert!( + ir.struct_schemas.contains_key(name), + "catalog-free HTTP parse must install {name}" + ); + } + catalog_free_parse("fn go() -> SseCallbackAction { { action: \"continue\" } }") + .expect("SseCallbackAction must parse on the catalog-free HTTP path"); +} + +#[cfg(not(all(feature = "http-client", not(target_family = "wasm"))))] +#[test] +fn catalog_free_without_http_installs_no_fallback_structs() { + let ir = catalog_free_parse("1;").expect("trivial source must parse"); + assert_unrelated_standard_structs_absent(&ir); + for name in HTTP_NAMED_STRUCTS { + assert!( + !ir.struct_schemas.contains_key(name), + "catalog-free parse without HTTP must not install {name}" + ); + } + let error = catalog_free_parse("fn go() -> SseCallbackAction { { action: \"continue\" } }") + .expect_err("SSE structs must be unknown without HTTP"); + assert!( + error + .message + .contains("unknown struct schema 'SseCallbackAction'"), + "without HTTP, SseCallbackAction must stay unknown, got {}", + error.message + ); +} + +#[test] +fn custom_catalog_remains_authoritative() { + let catalog = widget_catalog(); + let compiled = compile_source_with_flavor_and_options( + r#" + use widget; + fn go() -> Widget { { label: "ok" } } + widget::origin(); + "#, + SourceFlavor::RustScript, + CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)), + ) + .expect("custom catalog named structs must compile"); + assert!( + compiled + .program + .imports + .iter() + .any(|import| import.name == "widget::origin"), + "custom catalog host functions must remain visible" + ); + assert!( + !compiled.program.named_struct_decls().contains_key("Widget"), + "custom catalog structs must stay registry-side, not on the guest VMBC table" + ); + + let sse_message = match compile_source_with_flavor_and_options( + r#"fn go() -> SseCallbackAction { { action: "continue" } }"#, + SourceFlavor::RustScript, + CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)), + ) { + Ok(_) => panic!("custom catalog must not inherit HTTP fallback structs"), + Err(err) => err.to_string(), + }; + assert!( + sse_message.contains("unknown struct schema 'SseCallbackAction'"), + "custom catalog must keep SseCallbackAction unknown, got {sse_message}" + ); + + let jit_message = match compile_source_with_flavor_and_options( + "fn go() -> JitConfig { 1 }", + SourceFlavor::RustScript, + CompileSourceFileOptions::default().with_host_api_catalog(catalog), + ) { + Ok(_) => panic!("custom catalog must not inherit JitConfig"), + Err(err) => err.to_string(), + }; + assert!( + jit_message.contains("unknown struct schema 'JitConfig'"), + "custom catalog must keep JitConfig unknown, got {jit_message}" + ); +} + +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +#[test] +fn compile_source_file_without_catalog_admits_sse_named_structs() { + let temp = TempRssPath::new("pd_vm_catalog_free_sse_file"); + std::fs::write( + &temp.path, + "fn go() -> SseCallbackAction { { action: \"continue\" } }\n", + ) + .expect("temp rss must write"); + compile_source_file(&temp.path).unwrap_or_else(|err| { + panic!("file frontend without an explicit catalog must admit SseCallbackAction, got {err}") + }); +} + +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +#[test] +fn compile_source_for_repl_without_catalog_admits_sse_named_structs() { + compile_source_for_repl("fn go() -> SseCallbackAction { { action: \"continue\" } }") + .unwrap_or_else(|err| { + panic!("REPL without an explicit catalog must admit SseCallbackAction, got {err}") + }); +} + +#[cfg(not(all(feature = "http-client", not(target_family = "wasm"))))] +#[test] +fn compile_without_http_does_not_reserve_jit_config() { + let compile_message = match compile_source("fn go() -> JitConfig { 1 }") { + Ok(_) => panic!("JitConfig must not compile without a catalog"), + Err(err) => err.to_string(), + }; + assert!( + compile_message.contains("unknown struct schema 'JitConfig'"), + "no-http compile must not reserve JitConfig, got {compile_message}" + ); + + let repl_message = match compile_source_for_repl("fn go() -> JitConfig { 1 }") { + Ok(_) => panic!("REPL without HTTP must not reserve JitConfig"), + Err(err) => err.to_string(), + }; + assert!( + repl_message.contains("unknown struct schema 'JitConfig'"), + "no-http REPL must not reserve JitConfig, got {repl_message}" + ); +} diff --git a/tests/compiler/compiler_rustscript_tests.rs b/tests/compiler/compiler_rustscript_tests.rs index 94bb934a..1386185b 100644 --- a/tests/compiler/compiler_rustscript_tests.rs +++ b/tests/compiler/compiler_rustscript_tests.rs @@ -261,8 +261,7 @@ fn rustscript_builtin_and_namespace_runtime_cases_work() { use jit; let _set = jit::set_hot_loop_threshold(3); let after = jit::get_hot_loop_threshold(); - let cfg = jit::get_config(); - if after == 3 && cfg.hot_loop_threshold == 3 { + if after == 3 { 1; } else { 0; diff --git a/tests/fixtures/external-host-extension/src/lib.rs b/tests/fixtures/external-host-extension/src/lib.rs index 8849854c..bcb04697 100644 --- a/tests/fixtures/external-host-extension/src/lib.rs +++ b/tests/fixtures/external-host-extension/src/lib.rs @@ -96,6 +96,7 @@ pub struct DemoPolicy { pub struct CounterOp { pub remaining: u64, pub cancelled: Arc, + pub quiescent: bool, } impl vm::operation::HostOperation for CounterOp { @@ -117,8 +118,13 @@ impl vm::operation::HostOperation for CounterOp { ) -> vm::operation::OperationResult<()> { self.cancelled.fetch_add(1, Ordering::SeqCst); self.remaining = 0; + self.quiescent = true; Ok(()) } + + fn is_quiescent(&self) -> bool { + self.quiescent + } } // ---- catalog --------------------------------------------------------------- @@ -249,6 +255,7 @@ fn spawn_op(vm: &mut Vm, _args: &[Value]) -> VmResult { let spec = vm::operation::OperationSpec::new(CounterOp { remaining: 2, cancelled: Arc::clone(&cancelled), + quiescent: false, }); let id = vm .host_context() @@ -545,6 +552,7 @@ fn reset_driven_scope_cleanup_closes_resources_and_cancels_operations() { let spec = vm::operation::OperationSpec::new(CounterOp { remaining: 200, cancelled: Arc::clone(&cancelled), + quiescent: false, }); vm.host_context().start_operation(spec).expect("op start"); assert_eq!(vm.host_context().resource_count(), 2); diff --git a/tests/host_binding_generation_tests.rs b/tests/host_binding_generation_tests.rs index e12b5050..6f21eaab 100644 --- a/tests/host_binding_generation_tests.rs +++ b/tests/host_binding_generation_tests.rs @@ -8,8 +8,8 @@ use build_script::{ }; use syn::parse_quote; use vm::{ - BuiltinFunction, CapabilityProfile, HostFunctionRegistry, JitConfig, JitTraceTerminal, Value, - Vm, VmStatus, compile_source, + BuiltinFunction, CapabilityProfile, HostExecution, HostFunctionRegistry, JitConfig, + JitTraceTerminal, Value, Vm, VmStatus, compile_source, default_host_callables, }; fn native_jit_supported() -> bool { @@ -331,6 +331,51 @@ fn restricted_capabilities_disable_trace_jit_for_host_imports_and_builtins() { } } +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +#[test] +fn generated_http_imports_are_unique_typed_and_independently_capability_gated() { + const IMPORTS: [&str; 2] = ["http::client::request", "http::client::sse"]; + let callables = default_host_callables(); + for name in IMPORTS { + let discovered = callables + .iter() + .filter(|callable| callable.name == name) + .collect::>(); + assert_eq!(discovered.len(), 1, "{name} discovery count"); + let callable = discovered[0]; + assert_eq!(callable.signature.return_type, "map"); + if name == "http::client::request" { + assert_eq!(callable.signature.params.len(), 1); + assert_eq!(callable.signature.params[0].ty.display_label(), "map"); + } else { + assert_eq!(callable.signature.params.len(), 2); + assert_eq!(callable.signature.params[0].ty.display_label(), "map"); + assert_eq!( + callable.signature.params[1].ty.display_label(), + "fn(map) -> map" + ); + assert_eq!(callable.host_execution, HostExecution::MaySuspend); + } + } + + for mask in 0_u8..4 { + let mut builder = CapabilityProfile::builder(); + for (index, name) in IMPORTS.iter().enumerate() { + if mask & (1 << index) != 0 { + builder = builder.allow_host_import(*name); + } + } + let profile = builder.build(); + for (index, name) in IMPORTS.iter().enumerate() { + assert_eq!( + profile.allows_host_import(name), + mask & (1 << index) != 0, + "mask {mask:02b}, import {name}" + ); + } + } +} + #[test] fn capability_profile_fingerprint_uses_stable_callable_identities() { let first = CapabilityProfile::builder() diff --git a/tests/host_named_struct_foundation_tests.rs b/tests/host_named_struct_foundation_tests.rs new file mode 100644 index 00000000..da04036e --- /dev/null +++ b/tests/host_named_struct_foundation_tests.rs @@ -0,0 +1,430 @@ +//! Named host-struct schema foundation: catalog mapping, field access, +//! object-literal call compatibility, and display/hover labels. +//! +//! Runtime values remain maps; `HostTypeSchema::Map` dynamic semantics are +//! unchanged. HTTP/SQLite/JIT catalogs are not migrated here. + +use std::sync::Arc; + +use vm::compiler::{ + CompileSourceFileOptions, SourceFlavor, TypeSchema, compile_source_with_flavor_and_options, +}; +use vm::host_api::{ + HostApiBuilder, HostApiCatalog, HostFunctionSchema, HostParamPassing, HostParamSchema, + HostStructField, HostStructSchema, HostTypeSchema, ResourceTypeKey, ResourceTypeSchema, +}; +use vm::{ + CallOutcome, CallReturn, CompiledProgram, HostExtension, HostFunctionRegistry, SourcePathError, + SourcePosition, Vm, analyze_source_from_string_with_options, +}; + +fn point_fields() -> Vec { + vec![ + HostStructField::new("x", HostTypeSchema::Int), + HostStructField::new("y", HostTypeSchema::Int), + ] +} + +fn point_struct() -> HostStructSchema { + HostStructSchema::new("Point", point_fields()) +} + +fn point_type() -> HostTypeSchema { + point_struct().as_type() +} + +fn point_catalog() -> Arc { + let mut builder = HostApiBuilder::new(); + builder.named_struct(point_struct()); + builder.function(HostFunctionSchema::with_return( + "geo::origin", + vec![], + point_type(), + )); + builder.function(HostFunctionSchema::with_return( + "geo::take_point", + vec![HostParamSchema::value("p", point_type())], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "geo::open_map", + vec![], + HostTypeSchema::Map(Box::new(HostTypeSchema::Int)), + )); + Arc::new(builder.build().expect("point catalog")) +} + +fn compile(source: &str, catalog: Arc) -> Result { + compile_source_with_flavor_and_options( + source, + SourceFlavor::RustScript, + CompileSourceFileOptions::default().with_host_api_catalog(catalog), + ) +} + +fn compile_err(source: &str, catalog: Arc) -> String { + match compile(source, catalog) { + Ok(_) => panic!("expected compile error"), + Err(err) => err.to_string(), + } +} + +#[test] +fn named_struct_return_allows_field_access() { + let compiled = compile( + r#" + use geo; + let p = geo::origin(); + let s = p.x + p.y; + "#, + point_catalog(), + ) + .expect("field access on named host struct should compile"); + assert!( + compiled + .program + .imports + .iter() + .any(|import| import.name == "geo::origin") + ); +} + +#[test] +fn object_literal_is_compatible_with_named_struct_param() { + compile( + r#" + use geo; + geo::take_point({ x: 1, y: 2 }); + "#, + point_catalog(), + ) + .expect("object literal should match named struct param"); +} + +#[test] +fn object_literal_missing_field_is_rejected() { + let message = compile_err( + r#" + use geo; + geo::take_point({ x: 1 }); + "#, + point_catalog(), + ); + assert!( + message.contains("Point") || message.contains("field") || message.contains("match"), + "diagnostic should mention the mismatch, got {message}" + ); +} + +#[test] +fn dynamic_map_is_not_a_named_struct() { + let message = compile_err( + r#" + use geo; + let m = geo::open_map(); + geo::take_point(m); + "#, + point_catalog(), + ); + assert!( + message.contains("Point") || message.contains("map") || message.contains("match"), + "diagnostic should distinguish map from named struct, got {message}" + ); +} + +#[test] +fn hover_label_uses_named_struct_name() { + let source = "use geo;\nlet p = geo::origin();\n"; + let model = analyze_source_from_string_with_options( + "named_struct.rss", + source, + CompileSourceFileOptions::default().with_host_api_catalog(point_catalog()), + ) + .expect("analyze named struct program"); + let offset = source.find("let p").expect("binding") + 4; + let schema = model + .inferred_schema_at(SourcePosition::new(0, offset)) + .expect("hover on named struct local"); + assert_eq!(schema, TypeSchema::Named("Point".to_string(), vec![])); +} + +#[test] +fn nested_resource_field_is_preserved_in_compiler_schema() { + let file = ResourceTypeKey::new("io.file").expect("key"); + let handle = HostStructSchema::new( + "HandleBox", + vec![HostStructField::new( + "file", + HostTypeSchema::Resource(file.clone()), + )], + ); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(file.clone(), "file")); + builder.named_struct(handle.clone()); + builder.function(HostFunctionSchema::with_return( + "handles::open", + vec![], + handle.as_type(), + )); + builder.function(HostFunctionSchema::with_return( + "handles::borrow", + vec![HostParamSchema::with_passing( + "h", + handle.as_type(), + HostParamPassing::Borrow, + )], + HostTypeSchema::Null, + )); + let catalog = Arc::new(builder.build().expect("handle catalog")); + let compiled = compile( + r#" + use handles; + let h = handles::open(); + h.file; + "#, + catalog.clone(), + ) + .expect("nested resource field access should compile"); + let origin_schema = &vm::catalog_import_schemas(&catalog, "handles::open")[0]; + assert_eq!(origin_schema.return_type, handle.as_type()); + assert!(origin_schema.return_type.contains_resource()); + let _ = compiled; +} + +fn handle_catalog() -> Arc { + let file = ResourceTypeKey::new("io.file").expect("key"); + let handle = HostStructSchema::new( + "HandleBox", + vec![HostStructField::new( + "file", + HostTypeSchema::Resource(file.clone()), + )], + ); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(file.clone(), "file")); + builder.named_struct(handle.clone()); + builder.function(HostFunctionSchema::with_return( + "handles::open", + vec![], + handle.as_type(), + )); + builder.function(HostFunctionSchema::with_return( + "handles::borrow", + vec![HostParamSchema::with_passing( + "h", + handle.as_type(), + HostParamPassing::Borrow, + )], + HostTypeSchema::Null, + )); + builder.function(HostFunctionSchema::with_return( + "handles::take", + vec![HostParamSchema::with_passing( + "h", + handle.as_type(), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Null, + )); + Arc::new(builder.build().expect("handle catalog")) +} + +fn empty_vm() -> Vm { + let compiled = compile("0;", point_catalog()).expect("empty program"); + Vm::try_new(compiled.program).expect("test VM construction must not fail") +} + +fn noop_host(_vm: &mut Vm, _args: &[vm::Value]) -> vm::VmResult { + Ok(CallOutcome::Return(CallReturn::None)) +} + +struct NamedHandleExtension { + catalog: Arc, + name: &'static str, + arity: u8, +} + +impl HostExtension for NamedHandleExtension { + fn catalog(&self) -> Option<&HostApiCatalog> { + Some(self.catalog.as_ref()) + } + + fn register(&self, registry: &mut HostFunctionRegistry) -> vm::VmResult<()> { + for schema in vm::catalog_import_schemas(self.catalog.as_ref(), self.name) { + registry.register_exact_static(self.name, self.arity, schema, noop_host)?; + } + Ok(()) + } +} + +#[test] +fn catalog_import_schema_preserves_named_identity_for_resource_struct() { + let catalog = handle_catalog(); + let schemas = vm::catalog_import_schemas(&catalog, "handles::borrow"); + assert_eq!( + schemas[0].params[0].schema, + HostTypeSchema::named_struct( + "HandleBox", + vec![HostStructField::new( + "file", + HostTypeSchema::Resource(ResourceTypeKey::new("io.file").expect("key")), + )], + ) + ); + assert!(schemas[0].params[0].schema.contains_resource()); + let schemas = vm::catalog_import_schemas(&catalog, "handles::open"); + assert_eq!( + schemas[0].return_type, + HostTypeSchema::named_struct( + "HandleBox", + vec![HostStructField::new( + "file", + HostTypeSchema::Resource(ResourceTypeKey::new("io.file").expect("key")), + )], + ) + ); + assert!(schemas[0].return_type.contains_resource()); +} + +#[test] +fn resource_bearing_named_param_is_detected_at_exact_registration() { + let catalog = handle_catalog(); + let schema = vm::catalog_import_schemas(&catalog, "handles::borrow") + .into_iter() + .next() + .expect("borrow schema"); + assert!( + schema.params[0].schema.contains_resource(), + "named struct with a nested resource must classify as resource-bearing" + ); + let mut registry = vm::HostFunctionRegistry::empty(); + registry + .install_named_struct_schemas(vm::catalog_named_struct_schemas(&catalog)) + .expect("install named struct schemas"); + assert!( + matches!( + registry.named_struct_schemas().get("HandleBox"), + Some(TypeSchema::Object(fields)) + if fields.get("file").is_some_and(|ty| matches!(ty, TypeSchema::Resource(_))) + ), + "installed named-struct body must expose the nested resource" + ); + registry + .register_exact_static("handles::borrow", 1, schema, |_, _| { + Ok(vm::CallOutcome::Return(vm::CallReturn::None)) + }) + .expect("target registry accepts nested resources as named-struct maps"); +} + +#[test] +fn resource_bearing_named_return_is_classified_without_rejecting_registration() { + let catalog = handle_catalog(); + let schema = vm::catalog_import_schemas(&catalog, "handles::open") + .into_iter() + .next() + .expect("open schema"); + assert_eq!( + schema.return_type, + HostTypeSchema::named_struct( + "HandleBox", + vec![HostStructField::new( + "file", + HostTypeSchema::Resource(ResourceTypeKey::new("io.file").expect("key")), + )], + ) + ); + assert!(schema.return_type.contains_resource()); + let mut registry = vm::HostFunctionRegistry::empty(); + registry + .install_named_struct_schemas(vm::catalog_named_struct_schemas(&catalog)) + .expect("install named struct schemas"); + assert!( + matches!( + registry.named_struct_schemas().get("HandleBox"), + Some(TypeSchema::Object(fields)) + if fields.get("file").is_some_and(|ty| matches!(ty, TypeSchema::Resource(_))) + ), + "installed named-struct body must expose the nested resource" + ); + registry + .register_exact_static("handles::open", 0, schema, |_, _| { + Ok(vm::CallOutcome::Return(vm::CallReturn::None)) + }) + .expect("target registry accepts named-struct returns with nested resources"); +} + +#[test] +fn install_extension_installs_named_struct_bodies_without_manual_schema_install() { + let mut vm = empty_vm(); + vm.install_extension(&NamedHandleExtension { + catalog: handle_catalog(), + name: "handles::borrow", + arity: 1, + }) + .expect("target install_extension accepts nested resources as named-struct maps"); +} + +#[test] +fn catalog_import_schemas_into_installs_named_struct_bodies() { + let catalog = handle_catalog(); + let mut registry = HostFunctionRegistry::empty(); + let schema = vm::catalog_import_schemas_into(&mut registry, &catalog, "handles::borrow") + .expect("catalog import") + .into_iter() + .next() + .expect("borrow schema"); + assert!(schema.params[0].schema.contains_resource()); + assert!( + matches!( + registry.named_struct_schemas().get("HandleBox"), + Some(TypeSchema::Object(fields)) + if fields.get("file").is_some_and(|ty| matches!(ty, TypeSchema::Resource(_))) + ), + "catalog_import_schemas_into must install named-struct bodies" + ); + registry + .register_exact_static("handles::borrow", 1, schema, noop_host) + .expect("target registry accepts nested resources after catalog import"); +} + +#[test] +fn named_struct_fields_are_inline_without_a_side_table() { + let catalog = handle_catalog(); + let schema = vm::catalog_import_schemas(&catalog, "handles::borrow") + .into_iter() + .next() + .expect("borrow schema"); + assert!(schema.params[0].schema.contains_resource()); + let mut registry = HostFunctionRegistry::empty(); + registry + .register_exact_static("handles::borrow", 1, schema, noop_host) + .expect("HostTypeSchema::Named carries fields inline, so registration does not require a side table"); +} + +#[test] +fn install_named_struct_schemas_merges_and_rejects_conflicts() { + let catalog = handle_catalog(); + let mut registry = HostFunctionRegistry::empty(); + registry + .install_named_struct_schemas(vm::catalog_named_struct_schemas(&catalog)) + .expect("first install"); + registry + .install_named_struct_schemas(vm::catalog_named_struct_schemas(&catalog)) + .expect("identical catalog reinstall must merge"); + let mut conflict = std::collections::HashMap::new(); + conflict.insert("HandleBox".to_string(), TypeSchema::Int); + let error = registry + .install_named_struct_schemas(conflict) + .expect_err("conflicting HandleBox body must be rejected"); + assert!( + matches!(error, vm::VmError::HostError(ref message) if message.contains("conflicting named struct schema")), + "unexpected error: {error:?}" + ); + assert!( + matches!( + registry.named_struct_schemas().get("HandleBox"), + Some(TypeSchema::Object(_)) + ), + "rejected conflict must leave the original HandleBox body in place" + ); +} diff --git a/tests/http_feature_gating_tests.rs b/tests/http_feature_gating_tests.rs new file mode 100644 index 00000000..b72b889a --- /dev/null +++ b/tests/http_feature_gating_tests.rs @@ -0,0 +1,52 @@ +#[test] +fn http_callables_follow_the_http_client_feature_gate() { + for name in ["http::client::request", "http::client::sse"] { + let published = vm::default_host_callables() + .iter() + .any(|callable| callable.name == name); + assert_eq!( + published, + cfg!(all(feature = "http-client", not(target_family = "wasm"))), + "{name}" + ); + } +} + +#[test] +fn http_standard_catalog_entries_follow_the_native_transport_gate() { + let catalog = vm::standard_host_catalog(); + for name in ["http::client::request", "http::client::sse"] { + let published = catalog + .functions() + .iter() + .any(|function| function.name == name); + assert_eq!( + published, + cfg!(all(feature = "http-client", not(target_family = "wasm"))), + "{name}" + ); + } +} + +#[cfg(all(feature = "http-client", not(target_family = "wasm")))] +#[test] +fn sse_callable_metadata_has_exact_stream_schema() { + let callable = vm::default_host_callables() + .iter() + .find(|callable| callable.name == "http::client::sse") + .expect("SSE callable should be published"); + assert_eq!( + callable + .signature + .params + .iter() + .map(|param| (param.name, param.ty.display_label(), param.optional)) + .collect::>(), + [ + ("request", "map".to_string(), false), + ("on_event", "fn(map) -> map".to_string(), false), + ] + ); + assert_eq!(callable.signature.return_type, "map"); + assert_eq!(callable.host_execution, vm::HostExecution::MaySuspend); +} diff --git a/tests/http_named_struct_contract_tests.rs b/tests/http_named_struct_contract_tests.rs new file mode 100644 index 00000000..2e538b27 --- /dev/null +++ b/tests/http_named_struct_contract_tests.rs @@ -0,0 +1,1000 @@ +//! HTTP/SSE named-struct host contract: catalog identity, field access, +//! object-literal params, typed header/body values, and SSE events. +//! +//! Runtime values remain `Value::Map` carriers for named structs. Public +//! HTTP/SSE schemas use named records and typed entry arrays. + +#![cfg(feature = "http-client")] + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::task::{Context, Poll}; +use std::thread; + +use vm::compiler::{ + CompileSourceFileOptions, SourceFlavor, compile_source_with_flavor_and_options, +}; +use vm::{ + CallReturn, HostAsyncBridge, HostFunctionRegistry, HostFuture, HostFutureOutput, HostOpId, + HostStructSchema, HostTypeSchema, HttpConfig, HttpHostExt, Value, Vm, VmError, VmResult, + VmStatus, catalog_import_schemas, compile_source, http_host_catalog, + register_http_builtin_module, standard_host_catalog, +}; + +fn opt(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Optional(Box::new(inner)) +} + +fn array(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Array(Box::new(inner)) +} + +fn struct_by_name<'a>(catalog: &'a vm::HostApiCatalog, name: &str) -> &'a HostStructSchema { + catalog + .struct_named(name) + .unwrap_or_else(|| panic!("catalog must declare named struct {name}")) +} + +fn field_names(schema: &HostStructSchema) -> Vec<&str> { + schema + .fields + .iter() + .map(|field| field.name.as_str()) + .collect() +} + +fn field_ty<'a>(schema: &'a HostStructSchema, name: &str) -> &'a HostTypeSchema { + &schema + .fields + .iter() + .find(|field| field.name == name) + .unwrap_or_else(|| panic!("{} must declare field {name}", schema.name)) + .ty +} + +fn compile_ok(source: &str) { + compile_with_http_catalog(source) + .unwrap_or_else(|err| panic!("expected compile success, got {err}")); +} + +fn compile_err(source: &str) -> String { + match compile_with_http_catalog(source) { + Ok(_) => panic!("expected compile error"), + Err(err) => err.to_string(), + } +} + +fn compile_with_http_catalog(source: &str) -> Result { + compile_source_with_flavor_and_options( + source, + SourceFlavor::RustScript, + CompileSourceFileOptions::default().with_host_api_catalog(standard_host_catalog()), + ) +} + +#[test] +fn http_catalog_declares_fully_typed_named_structs() { + let catalog = http_host_catalog(); + let names: Vec<&str> = catalog.structs().iter().map(|s| s.name.as_str()).collect(); + assert_eq!( + names, + [ + "HttpRequestHeader", + "HttpHeaderValue", + "HttpResponseHeader", + "HttpRequestBody", + "SseEvent", + "HttpRequest", + "SseRequest", + "HttpResponse", + "SseCallbackAction", + "SseSummary" + ] + ); + + let request_header = struct_by_name(&catalog, "HttpRequestHeader"); + assert_eq!(field_names(request_header), ["name", "value"]); + assert_eq!(field_ty(request_header, "name"), &HostTypeSchema::String); + assert_eq!(field_ty(request_header, "value"), &HostTypeSchema::String); + + let header_value = struct_by_name(&catalog, "HttpHeaderValue"); + assert_eq!(field_names(header_value), ["kind", "text", "bytes"]); + assert_eq!(field_ty(header_value, "kind"), &HostTypeSchema::String); + assert_eq!(field_ty(header_value, "text"), &opt(HostTypeSchema::String)); + assert_eq!(field_ty(header_value, "bytes"), &opt(HostTypeSchema::Bytes)); + + let response_header = struct_by_name(&catalog, "HttpResponseHeader"); + assert_eq!(field_names(response_header), ["name", "value"]); + assert_eq!(field_ty(response_header, "name"), &HostTypeSchema::String); + assert_eq!(field_ty(response_header, "value"), &header_value.as_type()); + + let request_body = struct_by_name(&catalog, "HttpRequestBody"); + assert_eq!(field_names(request_body), ["kind", "text", "bytes"]); + assert_eq!(field_ty(request_body, "kind"), &HostTypeSchema::String); + assert_eq!(field_ty(request_body, "text"), &opt(HostTypeSchema::String)); + assert_eq!(field_ty(request_body, "bytes"), &opt(HostTypeSchema::Bytes)); + + let event = struct_by_name(&catalog, "SseEvent"); + assert_eq!( + field_names(event), + [ + "kind", "status", "headers", "url", "event", "data", "id", "retry_ms" + ] + ); + assert_eq!(field_ty(event, "kind"), &HostTypeSchema::String); + assert_eq!(field_ty(event, "status"), &opt(HostTypeSchema::Int)); + assert_eq!( + field_ty(event, "headers"), + &opt(array(response_header.as_type())) + ); + assert_eq!(field_ty(event, "url"), &opt(HostTypeSchema::String)); + assert_eq!(field_ty(event, "event"), &opt(HostTypeSchema::String)); + assert_eq!(field_ty(event, "data"), &opt(HostTypeSchema::String)); + assert_eq!(field_ty(event, "id"), &opt(HostTypeSchema::String)); + assert_eq!(field_ty(event, "retry_ms"), &opt(HostTypeSchema::Int)); + + let request = struct_by_name(&catalog, "HttpRequest"); + assert_eq!(field_names(request), ["method", "url", "headers", "body"]); + assert_eq!(field_ty(request, "method"), &HostTypeSchema::String); + assert_eq!(field_ty(request, "url"), &HostTypeSchema::String); + assert_eq!( + field_ty(request, "headers"), + &opt(array(request_header.as_type())) + ); + assert_eq!(field_ty(request, "body"), &opt(request_body.as_type())); + + let sse_request = struct_by_name(&catalog, "SseRequest"); + assert_eq!( + field_names(sse_request), + ["method", "url", "headers", "body", "timeout_ms"] + ); + assert_eq!(field_ty(sse_request, "method"), &HostTypeSchema::String); + assert_eq!(field_ty(sse_request, "url"), &HostTypeSchema::String); + assert_eq!( + field_ty(sse_request, "headers"), + &opt(array(request_header.as_type())) + ); + assert_eq!(field_ty(sse_request, "body"), &opt(request_body.as_type())); + assert_eq!( + field_ty(sse_request, "timeout_ms"), + &opt(HostTypeSchema::Int) + ); + + let response = struct_by_name(&catalog, "HttpResponse"); + assert_eq!(field_names(response), ["status", "headers", "body", "url"]); + assert_eq!(field_ty(response, "status"), &HostTypeSchema::Int); + assert_eq!( + field_ty(response, "headers"), + &array(response_header.as_type()) + ); + assert_eq!(field_ty(response, "body"), &HostTypeSchema::Bytes); + assert_eq!(field_ty(response, "url"), &HostTypeSchema::String); + + let action = struct_by_name(&catalog, "SseCallbackAction"); + assert_eq!(field_names(action), ["action"]); + assert_eq!(field_ty(action, "action"), &HostTypeSchema::String); + + let summary = struct_by_name(&catalog, "SseSummary"); + assert_eq!( + field_names(summary), + [ + "outcome", + "status", + "headers", + "url", + "items", + "bytes_received", + "bytes_sent" + ] + ); + assert_eq!(field_ty(summary, "outcome"), &HostTypeSchema::String); + assert_eq!(field_ty(summary, "status"), &HostTypeSchema::Int); + assert_eq!( + field_ty(summary, "headers"), + &array(response_header.as_type()) + ); + assert_eq!(field_ty(summary, "url"), &HostTypeSchema::String); + assert_eq!(field_ty(summary, "items"), &HostTypeSchema::Int); + assert_eq!(field_ty(summary, "bytes_received"), &HostTypeSchema::Int); + assert_eq!(field_ty(summary, "bytes_sent"), &HostTypeSchema::Int); +} + +#[test] +fn http_request_and_sse_use_named_request_response_and_action_types() { + let catalog = http_host_catalog(); + let request = catalog + .function("http::client::request") + .expect("http::client::request"); + assert_eq!( + request.params[0].ty, + struct_by_name(&catalog, "HttpRequest").as_type() + ); + assert_eq!( + request.return_type, + struct_by_name(&catalog, "HttpResponse").as_type() + ); + + let sse = catalog + .function("http::client::sse") + .expect("http::client::sse"); + assert_eq!( + sse.params[0].ty, + struct_by_name(&catalog, "SseRequest").as_type() + ); + assert_eq!( + sse.params[1].ty, + HostTypeSchema::Callable { + params: vec![struct_by_name(&catalog, "SseEvent").as_type()], + result: Box::new(struct_by_name(&catalog, "SseCallbackAction").as_type()), + } + ); + assert_eq!( + sse.return_type, + struct_by_name(&catalog, "SseSummary").as_type() + ); +} + +#[test] +fn compiler_import_schemas_preserve_named_identity() { + let catalog = http_host_catalog(); + let request = &catalog_import_schemas(&catalog, "http::client::request")[0]; + assert_eq!( + request.params[0].schema, + struct_by_name(&catalog, "HttpRequest").as_type() + ); + assert_eq!( + request.return_type, + struct_by_name(&catalog, "HttpResponse").as_type() + ); + let sse = &catalog_import_schemas(&catalog, "http::client::sse")[0]; + assert_eq!( + sse.params[0].schema, + struct_by_name(&catalog, "SseRequest").as_type() + ); + assert_eq!( + sse.params[1].schema, + HostTypeSchema::Callable { + params: vec![struct_by_name(&catalog, "SseEvent").as_type()], + result: Box::new(struct_by_name(&catalog, "SseCallbackAction").as_type()), + } + ); + assert_eq!( + sse.return_type, + struct_by_name(&catalog, "SseSummary").as_type() + ); +} + +#[test] +fn object_literal_is_accepted_for_named_http_request() { + compile_ok( + r#" + use http; + http::client::request({ method: "GET", url: "http://127.0.0.1:1/x" }); + "#, + ); +} + +#[test] +fn object_literal_with_typed_headers_is_accepted() { + compile_ok( + r#" + use http; + http::client::request({ + method: "POST", + url: "http://127.0.0.1:1/x", + headers: [{ name: "content-type", value: "application/json" }], + body: { kind: "text", text: "{}" } + }); + "#, + ); +} + +#[test] +fn object_literal_with_typed_byte_body_is_accepted() { + compile_ok( + r#" + use http; + http::client::request({ + method: "POST", + url: "http://127.0.0.1:1/x", + body: { kind: "bytes", bytes: b"raw-body" } + }); + "#, + ); +} + +#[test] +fn legacy_dynamic_headers_and_scalar_body_are_rejected() { + let scalar_body = compile_err( + r#" + use http; + http::client::request({ + method: "POST", + url: "http://127.0.0.1:1/x", + body: "raw-body" + }); + "#, + ); + assert!( + scalar_body.contains("no host function `http::client::request` matches the arguments"), + "scalar request body must be rejected by the typed schema: {scalar_body}" + ); + + let dynamic_headers = compile_err( + r#" + use http; + http::client::request({ + method: "GET", + url: "http://127.0.0.1:1/x", + headers: { "x-test": "value" } + }); + "#, + ); + assert!( + dynamic_headers.contains("no host function `http::client::request` matches the arguments"), + "header maps must be rejected by the typed schema: {dynamic_headers}" + ); +} + +#[test] +fn object_literal_missing_required_method_is_rejected() { + let message = compile_err( + r#" + use http; + http::client::request({ url: "http://127.0.0.1:1/x" }); + "#, + ); + assert!( + message.contains("no host function `http::client::request` matches the arguments"), + "missing method must name the host function, got {message}" + ); + assert!( + message.contains("expected HttpRequest"), + "missing method must name HttpRequest, got {message}" + ); + assert!( + message.contains("url: string"), + "missing method diagnostic must show the found object, got {message}" + ); +} + +#[test] +fn buffered_request_rejects_sse_only_timeout_ms() { + let message = compile_err( + r#" + use http; + http::client::request({ + method: "GET", + url: "http://127.0.0.1:1/x", + timeout_ms: 20 + }); + "#, + ); + assert!( + message.contains("no host function `http::client::request` matches the arguments"), + "SSE-only timeout_ms must not match HttpRequest, got {message}" + ); + assert!( + message.contains("expected HttpRequest"), + "buffered request mismatch must name HttpRequest, got {message}" + ); + assert!( + message.contains("timeout_ms"), + "buffered request mismatch must mention timeout_ms, got {message}" + ); +} + +#[test] +fn optional_null_headers_and_body_compile_for_buffered_request() { + compile_ok( + r#" + use http; + http::client::request({ + method: "GET", + url: "http://127.0.0.1:1/x", + headers: null, + body: null + }); + "#, + ); +} + +#[test] +fn optional_null_timeout_compiles_for_sse_request() { + compile_ok( + r#" + use http; + fn on_event(item: SseEvent) -> SseCallbackAction { + { action: "continue" } + } + http::client::sse( + { method: "GET", url: "http://127.0.0.1:1/events", timeout_ms: null }, + on_event + ); + "#, + ); +} + +#[test] +fn named_http_response_allows_field_access() { + compile_ok( + r#" + use http; + let response = http::client::request({ method: "GET", url: "http://127.0.0.1:1/x" }); + let status = response.status; + let url = response.url; + let body = response.body; + "#, + ); +} + +#[test] +fn named_http_response_rejects_unknown_field() { + let message = compile_err( + r#" + use http; + let response = http::client::request({ method: "GET", url: "http://127.0.0.1:1/x" }); + response.not_a_field; + "#, + ); + assert!( + message.contains("field 'not_a_field' is not declared"), + "unknown field must name the missing field, got {message}" + ); +} + +#[test] +fn named_http_response_rejects_unknown_string_index() { + let message = compile_err( + r#" + use http; + let response = http::client::request({ method: "GET", url: "http://127.0.0.1:1/x" }); + response["not_a_field"]; + "#, + ); + assert!( + message.contains("field 'not_a_field' is not declared"), + "unknown string index must name the missing field, got {message}" + ); +} + +#[test] +fn typed_response_headers_expose_ordered_entry_fields() { + compile_ok( + r#" + use http; + let response = http::client::request({ method: "GET", url: "http://127.0.0.1:1/x" }); + response.headers[0].value.kind; + "#, + ); +} + +#[test] +fn sse_object_literal_and_action_struct_compile() { + compile_ok( + r#" + use http; + fn on_event(item: SseEvent) -> SseCallbackAction { + { action: "continue" } + } + http::client::sse( + { method: "GET", url: "http://127.0.0.1:1/events" }, + on_event + ); + "#, + ); +} + +const SSE_NAMED_ACTION_SOURCE: &str = r#" + use http; + fn on_event(item: SseEvent) -> SseCallbackAction { + { action: "continue" } + } + http::client::sse( + { method: "GET", url: "http://127.0.0.1:1/events" }, + on_event + ); +"#; + +#[test] +fn compile_source_installs_sse_named_structs_and_exact_schema() { + let compiled = compile_source(SSE_NAMED_ACTION_SOURCE).unwrap_or_else(|err| { + panic!("default compile_source must admit SseCallbackAction, got {err}") + }); + let index = compiled + .program + .imports + .iter() + .position(|import| import.name == "http::client::sse") + .expect("default compile must admit http::client::sse"); + let schema = compiled + .program + .host_import_schemas() + .get(index) + .and_then(Option::as_ref) + .expect("default compile must emit the exact SSE import schema"); + assert_eq!(schema.fingerprint, http_host_catalog().fingerprint()); + let mut vm = Vm::new(compiled.program); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default registry must exact-bind catalog-backed SSE"); +} + +const HTTP_NAMED_STRUCTS: [&str; 10] = [ + "HttpRequestHeader", + "HttpHeaderValue", + "HttpResponseHeader", + "HttpRequestBody", + "SseEvent", + "HttpRequest", + "SseRequest", + "HttpResponse", + "SseCallbackAction", + "SseSummary", +]; + +#[test] +fn compile_source_http_program_guest_table_excludes_http_structs() { + let compiled = compile_source(SSE_NAMED_ACTION_SOURCE).unwrap_or_else(|err| { + panic!("default compile_source must admit SseCallbackAction, got {err}") + }); + for name in HTTP_NAMED_STRUCTS { + assert!( + !compiled.program.named_struct_decls().contains_key(name), + "guest VMBC table must not carry catalog struct {name}" + ); + } +} + +#[test] +fn unbound_vm_rejects_host_named_without_catalog_binding() { + let compiled = compile_source( + r#" + fn ident(p: SseCallbackAction) -> SseCallbackAction { p } + ident({ action: "continue" }); + "#, + ) + .unwrap_or_else(|err| panic!("SseCallbackAction program should compile, got {err}")); + for name in HTTP_NAMED_STRUCTS { + assert!( + !compiled.program.named_struct_decls().contains_key(name), + "guest table must not provide {name} as a fallback" + ); + } + let mut vm = Vm::new(compiled.program); + let error = loop { + match vm.run() { + Err(error) => break error, + Ok(VmStatus::Halted) => panic!("unbound host Named must fail closed"), + Ok(VmStatus::Yielded) => continue, + Ok(VmStatus::Waiting(_)) => panic!("SseCallbackAction ident should not wait"), + } + }; + match error { + VmError::HostError(message) => { + assert!( + message.contains("unknown named struct"), + "unexpected host error: {message}" + ); + } + other => panic!("expected HostError, got {other:?}"), + } +} + +#[test] +fn guest_struct_colliding_with_http_catalog_name_is_rejected() { + let message = compile_err( + r#" + struct SseCallbackAction { action: string } + fn ident(p: SseCallbackAction) -> SseCallbackAction { p } + ident({ action: "continue" }); + "#, + ); + assert!( + message.contains("duplicate struct schema 'SseCallbackAction'"), + "host catalog name must win over a guest collision, got {message}" + ); +} + +#[test] +fn options_compile_without_catalog_installs_sse_named_structs() { + let compiled = compile_source_with_flavor_and_options( + SSE_NAMED_ACTION_SOURCE, + SourceFlavor::RustScript, + CompileSourceFileOptions::default(), + ) + .unwrap_or_else(|err| { + panic!("default options compile must admit SseCallbackAction, got {err}") + }); + let index = compiled + .program + .imports + .iter() + .position(|import| import.name == "http::client::sse") + .expect("default options compile must admit http::client::sse"); + assert!( + compiled + .program + .host_import_schemas() + .get(index) + .and_then(Option::as_ref) + .is_some(), + "default options compile must emit the exact SSE import schema" + ); +} + +#[test] +fn sse_map_returning_callback_is_rejected_with_action_schema() { + let message = compile_err( + r#" + use http; + fn on_event(item: SseEvent) -> map { + { action: "continue" } + } + http::client::sse( + { method: "GET", url: "http://127.0.0.1:1/events" }, + on_event + ); + "#, + ); + assert!( + message.contains("no host function `http::client::sse` matches the arguments"), + "map-returning callback must fail the SSE host call, got {message}" + ); + assert!( + message.contains("SseCallbackAction"), + "SSE callback mismatch must name SseCallbackAction, got {message}" + ); + assert!( + message.contains("map"), + "SSE callback mismatch must mention the found map result, got {message}" + ); +} + +#[test] +fn sse_summary_allows_field_access() { + compile_ok( + r#" + use http; + fn on_event(item: SseEvent) -> SseCallbackAction { + { action: "stop" } + } + let summary = http::client::sse( + { method: "GET", url: "http://127.0.0.1:1/events" }, + on_event + ); + let outcome = summary.outcome; + let items = summary.items; + let received = summary.bytes_received; + let sent = summary.bytes_sent; + "#, + ); +} + +#[test] +fn sse_named_event_exposes_typed_fields() { + compile_ok( + r#" + use http; + fn on_event(item: SseEvent) -> SseCallbackAction { + if item.kind == "event" { + print(item.data); + } + { action: "continue" } + } + http::client::sse( + { method: "GET", url: "http://127.0.0.1:1/events", timeout_ms: 20 }, + on_event + ); + "#, + ); +} + +#[derive(Default)] +struct TokioHostDriver { + submitted: HashMap, +} + +impl HostAsyncBridge for TokioHostDriver { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + self.submitted.insert(op_id, future); + Ok(()) + } + + fn poll_op(&mut self, op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(VmError::HostError(format!( + "unknown external host operation {op_id}" + )))) + } + + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + let poll = self.submitted.get_mut(&op_id).map_or_else( + || { + Poll::Ready(Err(VmError::HostError(format!( + "unknown submitted host operation {op_id}" + )))) + }, + |future| future.as_mut().poll(cx), + ); + if poll.is_ready() { + self.submitted.remove(&op_id); + } + poll + } + + fn cancel_op(&mut self, op_id: HostOpId) { + self.submitted.remove(&op_id); + } +} + +fn standard_http_registry() -> HostFunctionRegistry { + let mut registry = HostFunctionRegistry::empty(); + register_http_builtin_module(&mut registry).expect("register HTTP"); + registry +} + +async fn drive_vm_to_halt(vm: &mut Vm) -> Result<(), VmError> { + let mut status = vm.run()?; + loop { + match status { + VmStatus::Halted => return Ok(()), + VmStatus::Yielded => status = vm.resume()?, + VmStatus::Waiting(_) => { + vm.await_waiting_host_op().await?; + status = vm.resume()?; + } + } + } +} + +fn local_http_config(port: u16) -> HttpConfig { + HttpConfig { + allowed_schemes: vec!["http".into()], + allowed_hosts: vec!["127.0.0.1".into()], + allowed_ports: vec![port], + allow_private_ips: true, + ..HttpConfig::default() + } +} + +fn bind_http_vm(source: &str, port: u16) -> Vm { + let compiled = compile_with_http_catalog(source).expect("source should compile"); + let mut vm = Vm::try_new(compiled.program).expect("vm"); + vm.configure_http(local_http_config(port)).expect("config"); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + standard_http_registry() + .bind_vm_cached(&mut vm) + .expect("bind"); + vm +} + +fn spawn_ok_server() -> (u16, thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = stream.read(&mut buffer).expect("read"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nX-Test: yes\r\n\r\nok") + .expect("write"); + }); + (port, server) +} + +fn spawn_post_body_server(expected: &'static [u8]) -> (u16, thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind"); + let port = listener.local_addr().expect("addr").port(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = stream.read(&mut buffer).expect("read"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") { + let header_end = header_end + 4; + let headers = std::str::from_utf8(&request[..header_end]).expect("headers utf8"); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + (name.eq_ignore_ascii_case("content-length")) + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .expect("content-length"); + while request.len() < header_end + content_length { + let read = stream.read(&mut buffer).expect("read body"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + assert_eq!(&request[header_end..header_end + content_length], expected); + break; + } + } + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .expect("write"); + }); + (port, server) +} + +#[tokio::test(flavor = "current_thread")] +async fn named_response_field_access_reads_runtime_map_carrier() { + let (port, server) = spawn_ok_server(); + let source = format!( + r#" + use http; + let response = http::client::request({{ method: "GET", url: "http://127.0.0.1:{port}/" }}); + response.status; + "# + ); + let mut vm = bind_http_vm(&source, port); + drive_vm_to_halt(&mut vm).await.expect("request"); + server.join().expect("server"); + assert_eq!(vm.stack()[0], Value::Int(200)); +} + +#[tokio::test(flavor = "current_thread")] +async fn byte_request_body_is_accepted_at_runtime() { + let (port, server) = spawn_post_body_server(b"raw-body"); + let source = format!( + r#" + use http; + let response = http::client::request({{ + method: "POST", + url: "http://127.0.0.1:{port}/", + body: {{ kind: "bytes", bytes: b"raw-body" }} + }}); + response.status; + "# + ); + let mut vm = bind_http_vm(&source, port); + drive_vm_to_halt(&mut vm) + .await + .expect("byte body request should complete"); + server.join().expect("server"); + assert_eq!(vm.stack()[0], Value::Int(200)); +} + +#[tokio::test(flavor = "current_thread")] +async fn null_headers_are_treated_as_omitted() { + let (port, server) = spawn_ok_server(); + let source = format!( + r#" + use http; + let response = http::client::request({{ + method: "GET", + url: "http://127.0.0.1:{port}/", + headers: null + }}); + response.status; + "# + ); + let mut vm = bind_http_vm(&source, port); + drive_vm_to_halt(&mut vm) + .await + .expect("null headers must match omitted headers"); + server.join().expect("server"); + assert_eq!(vm.stack()[0], Value::Int(200)); +} + +#[test] +fn null_sse_timeout_is_treated_as_omitted() { + let source = r#" + use http; + fn on_event(item: SseEvent) -> SseCallbackAction { { action: "continue" } } + http::client::sse( + { method: "GET", url: "http://127.0.0.1:1/events", timeout_ms: null }, + on_event + ); + "#; + let compiled = compile_with_http_catalog(source).expect("null timeout_ms should compile"); + let mut vm = Vm::try_new(compiled.program).expect("vm"); + vm.set_http_max_in_flight(0); + vm.configure_http(local_http_config(1)).expect("config"); + standard_http_registry() + .bind_vm_cached(&mut vm) + .expect("bind"); + let error = vm + .run() + .expect_err("zero in-flight must reject after timeout parse"); + assert!( + error.to_string().contains("in-flight request limit"), + "null timeout_ms must be omitted rather than type-mismatch, got {error}" + ); +} + +#[test] +fn sse_callback_runtime_schema_rejects_arbitrary_map_result() { + let compiled = compile_with_http_catalog( + r#" + pub fn callback(item: SseEvent) -> map { { action: "continue" } } + "#, + ) + .expect("map callback should compile in isolation"); + let mut vm = Vm::try_new(compiled.program).expect("vm"); + assert_eq!(vm.run().expect("run"), VmStatus::Halted); + let callback = vm + .resolve_exported_callable("callback") + .expect("export callback"); + vm.validate_stream_callback_value(&callback) + .expect("generic stream still accepts fn(map) -> map"); + let error = vm + .validate_sse_callback_value(&callback) + .expect_err("SSE must reject arbitrary map results"); + assert!( + matches!( + error, + VmError::TypeMismatch("fn(SseEvent) -> SseCallbackAction") + ), + "SSE callback diagnostic must name fn(SseEvent) -> SseCallbackAction, got {error:?}" + ); +} + +#[test] +fn generic_stream_callback_mismatch_describes_named_compatibility() { + let compiled = compile_with_http_catalog( + r#" + pub fn callback(value: int) -> int { value } + "#, + ) + .expect("mismatched callback should compile in isolation"); + let mut vm = Vm::try_new(compiled.program).expect("vm"); + assert_eq!(vm.run().expect("run"), VmStatus::Halted); + let callback = vm + .resolve_exported_callable("callback") + .expect("export callback"); + let error = vm + .validate_stream_callback_value(&callback) + .expect_err("generic stream should reject an incompatible callback"); + assert!( + matches!( + error, + VmError::TypeMismatch( + "callable stream callback must accept one map or named input and return a map, named value, or object" + ) + ), + "generic stream callback diagnostic must describe named compatibility, got {error:?}" + ); +} + +#[test] +fn sse_callback_runtime_schema_accepts_named_action() { + let compiled = compile_with_http_catalog( + r#" + use http; + pub fn callback(item: SseEvent) -> SseCallbackAction { { action: "continue" } } + "#, + ) + .expect("named action callback should compile"); + let mut vm = Vm::try_new(compiled.program).expect("vm"); + assert_eq!(vm.run().expect("run"), VmStatus::Halted); + let callback = vm + .resolve_exported_callable("callback") + .expect("export callback"); + vm.validate_sse_callback_value(&callback) + .expect("SseCallbackAction must be accepted"); +} diff --git a/tests/jit_named_struct_tests.rs b/tests/jit_named_struct_tests.rs new file mode 100644 index 00000000..e8a66b0d --- /dev/null +++ b/tests/jit_named_struct_tests.rs @@ -0,0 +1,485 @@ +//! JIT config host maps migrate to one named struct `JitConfig`. +//! +//! Runtime values remain maps. Production compile/bind uses the standard +//! catalog; `jit_host_catalog()` remains the JIT subcatalog. + +#![cfg(feature = "runtime")] + +use std::sync::Arc; + +use vm::compiler::{ + CompileSourceFileOptions, SourceFlavor, compile_source_with_flavor_and_options, +}; +use vm::host_api::{HostApiCatalog, HostTypeSchema}; +use vm::{ + CompiledProgram, HostFunctionRegistry, SourcePathError, Value, Vm, VmStatus, jit_host_catalog, + register_jit_builtin_module, register_jit_builtin_module_from_catalog, standard_host_catalog, +}; + +const JIT_CONFIG: &str = "JitConfig"; +const GET_CONFIG: &str = "jit::get_config"; +const SET_CONFIG: &str = "jit::set_config"; + +fn compile(source: &str, catalog: Arc) -> Result { + compile_source_with_flavor_and_options( + source, + SourceFlavor::RustScript, + CompileSourceFileOptions::default().with_host_api_catalog(catalog), + ) +} + +fn compile_err(source: &str, catalog: Arc) -> String { + match compile(source, catalog) { + Ok(_) => panic!("expected compile error"), + Err(err) => err.to_string(), + } +} + +fn jit_config_type(catalog: &HostApiCatalog) -> HostTypeSchema { + catalog + .struct_named(JIT_CONFIG) + .expect("JitConfig must be declared") + .as_type() +} + +fn run_jit_host(source: &str) -> Vec { + let catalog = jit_host_catalog(); + let compiled = compile(source, Arc::clone(&catalog)).expect("compile should succeed"); + let mut vm = Vm::try_new(compiled.program).expect("test VM construction must not fail"); + let mut registry = HostFunctionRegistry::empty(); + register_jit_builtin_module_from_catalog(&mut registry, catalog.as_ref()) + .expect("JIT exact registration should succeed"); + registry + .bind_vm_cached(&mut vm) + .expect("JIT exact host imports should bind"); + loop { + match vm.run().expect("vm should run") { + VmStatus::Halted => break, + VmStatus::Yielded => continue, + VmStatus::Waiting(_) => panic!("JIT config calls must not pending"), + } + } + vm.stack().to_vec() +} + +#[test] +fn jit_catalog_declares_named_config_struct() { + let catalog = jit_host_catalog(); + let schema = catalog + .struct_named(JIT_CONFIG) + .expect("JitConfig must be present in the JIT catalog"); + let names: Vec<&str> = schema + .fields + .iter() + .map(|field| field.name.as_str()) + .collect(); + assert_eq!( + names, + ["enabled", "hot_loop_threshold", "max_trace_len"], + "JitConfig fields must be exactly the implementation config keys" + ); + assert_eq!(schema.fields[0].ty, HostTypeSchema::Bool); + assert_eq!(schema.fields[1].ty, HostTypeSchema::Int); + assert_eq!(schema.fields[2].ty, HostTypeSchema::Int); +} + +#[test] +fn jit_get_config_returns_named_struct() { + let catalog = jit_host_catalog(); + let functions = catalog.functions_named(GET_CONFIG); + assert_eq!(functions.len(), 1); + assert!(functions[0].params.is_empty()); + assert_eq!(functions[0].return_type, jit_config_type(&catalog)); +} + +#[test] +fn jit_set_config_takes_named_struct() { + let catalog = jit_host_catalog(); + let named = catalog + .functions_named(SET_CONFIG) + .into_iter() + .find(|function| function.params.len() == 1) + .expect("jit::set_config(JitConfig) overload must exist"); + assert_eq!(named.params[0].name, "config"); + assert_eq!(named.params[0].ty, jit_config_type(&catalog)); + assert_eq!(named.return_type, jit_config_type(&catalog)); +} + +#[test] +fn jit_set_config_keeps_positional_overload() { + let catalog = jit_host_catalog(); + let positional = catalog + .functions_named(SET_CONFIG) + .into_iter() + .find(|function| function.params.len() == 3) + .expect("jit::set_config(bool, int, int) overload must exist"); + assert_eq!( + positional + .params + .iter() + .map(|param| (param.name.as_str(), ¶m.ty)) + .collect::>(), + [ + ("enabled", &HostTypeSchema::Bool), + ("hot_loop_threshold", &HostTypeSchema::Int), + ("max_trace_len", &HostTypeSchema::Int), + ] + ); + assert_eq!(positional.return_type, jit_config_type(&catalog)); +} + +#[test] +fn compiler_emits_named_struct_import_schemas() { + let catalog = jit_host_catalog(); + let compiled = compile( + r#" + use jit; + let cfg = jit::get_config(); + cfg.enabled; + "#, + Arc::clone(&catalog), + ) + .expect("field access on jit::get_config should compile"); + let schema = compiled + .program + .host_import_schemas() + .iter() + .flatten() + .find(|schema| schema.name == GET_CONFIG) + .expect("jit::get_config must have an import schema"); + assert_eq!(schema.return_type, jit_config_type(&catalog)); + assert_eq!(schema.fingerprint, catalog.fingerprint()); +} + +#[test] +fn field_access_on_get_config_compiles() { + compile( + r#" + use jit; + let cfg = jit::get_config(); + let _enabled = cfg.enabled; + let _hot = cfg.hot_loop_threshold; + let _max = cfg.max_trace_len; + "#, + jit_host_catalog(), + ) + .expect(".field access on JitConfig should compile"); +} + +#[test] +fn object_literal_set_config_compiles() { + compile( + r#" + use jit; + jit::set_config({ + enabled: true, + hot_loop_threshold: 3, + max_trace_len: 64 + }); + "#, + jit_host_catalog(), + ) + .expect("object literal should match JitConfig"); +} + +#[test] +fn unknown_field_is_rejected() { + let message = compile_err( + r#" + use jit; + jit::set_config({ + enabled: true, + hot_loop_threshold: 3, + max_trace_len: 64, + extra: 1 + }); + "#, + jit_host_catalog(), + ); + assert!( + message.contains("JitConfig") + || message.contains("field") + || message.contains("extra") + || message.contains("match"), + "unknown field should be rejected, got {message}" + ); +} + +#[test] +fn missing_field_is_rejected() { + let message = compile_err( + r#" + use jit; + jit::set_config({ enabled: true, hot_loop_threshold: 3 }); + "#, + jit_host_catalog(), + ); + assert!( + message.contains("JitConfig") + || message.contains("field") + || message.contains("max_trace_len") + || message.contains("match"), + "missing field should be rejected, got {message}" + ); +} + +#[test] +fn wrong_field_type_is_rejected() { + let message = compile_err( + r#" + use jit; + jit::set_config({ + enabled: 1, + hot_loop_threshold: 3, + max_trace_len: 64 + }); + "#, + jit_host_catalog(), + ); + assert!( + message.contains("JitConfig") + || message.contains("bool") + || message.contains("enabled") + || message.contains("match") + || message.contains("type"), + "wrong field type should be rejected, got {message}" + ); +} + +#[test] +fn exact_registration_resolves_jit_config_imports() { + let catalog = jit_host_catalog(); + let mut registry = HostFunctionRegistry::empty(); + register_jit_builtin_module(&mut registry).expect("register JIT"); + assert!( + registry.named_struct_schemas().contains_key(JIT_CONFIG), + "JIT exact registration must install JitConfig" + ); + assert_eq!( + vm::catalog_import_schemas(&catalog, GET_CONFIG)[0].return_type, + jit_config_type(&catalog) + ); +} + +#[test] +fn runtime_object_literal_and_field_access() { + let stack = run_jit_host( + r#" + use jit; + let _updated = jit::set_config({ + enabled: true, + hot_loop_threshold: 3, + max_trace_len: 64 + }); + let cfg = jit::get_config(); + cfg.hot_loop_threshold; + "#, + ); + assert_eq!(stack, vec![Value::Int(3)]); +} + +#[test] +fn runtime_carrier_remains_map() { + let catalog = jit_host_catalog(); + let compiled = compile("use jit; jit::get_config();", Arc::clone(&catalog)) + .expect("get_config should compile"); + let mut vm = Vm::try_new(compiled.program).expect("test VM construction must not fail"); + let mut registry = HostFunctionRegistry::empty(); + register_jit_builtin_module_from_catalog(&mut registry, catalog.as_ref()) + .expect("register JIT"); + registry.bind_vm_cached(&mut vm).expect("bind JIT"); + match vm.run().expect("run") { + VmStatus::Halted => {} + other => panic!("expected halt, got {other:?}"), + } + match vm.stack() { + [Value::Map(_)] => {} + other => panic!("runtime carrier must remain a map, got {other:?}"), + } +} + +#[test] +fn unrelated_generated_host_schemas_are_unchanged() { + let io = vm::io_host_catalog(); + assert!( + io.struct_named(JIT_CONFIG).is_none(), + "IO catalog must not gain JitConfig" + ); + assert!( + io.functions_named(GET_CONFIG).is_empty() && io.functions_named(SET_CONFIG).is_empty(), + "IO catalog must not declare jit config functions" + ); + #[cfg(feature = "http-client")] + { + let http = vm::http_host_catalog(); + assert!(http.struct_named(JIT_CONFIG).is_none()); + assert!(http.functions_named(GET_CONFIG).is_empty()); + assert!(http.functions_named(SET_CONFIG).is_empty()); + for function in http.functions() { + assert!( + !matches!(&function.return_type, HostTypeSchema::Named { name, .. } if name == JIT_CONFIG), + "HTTP function {} must not return JitConfig", + function.name + ); + } + } + for callable in vm::default_host_callables() { + assert!( + !callable.name.contains("jit::"), + "generated default host callables must not include {}", + callable.name + ); + } +} + +#[test] +fn standard_catalog_includes_typed_jit_config() { + let jit = jit_host_catalog(); + let standard = standard_host_catalog(); + assert_ne!( + jit.fingerprint(), + standard.fingerprint(), + "JIT-local catalog remains a subcatalog of the combined snapshot" + ); + let schema = standard + .struct_named(JIT_CONFIG) + .expect("standard catalog must declare JitConfig whenever JIT builtins are available"); + assert_eq!( + schema + .fields + .iter() + .map(|field| field.name.as_str()) + .collect::>(), + ["enabled", "hot_loop_threshold", "max_trace_len"] + ); + let get = standard.functions_named(GET_CONFIG); + assert_eq!(get.len(), 1); + assert!(get[0].params.is_empty()); + assert_eq!(get[0].return_type, jit_config_type(&standard)); + let set = standard.functions_named(SET_CONFIG); + assert_eq!(set.len(), 2, "named and positional set_config overloads"); + assert!(set.iter().any(|function| function.params.len() == 1)); + assert!(set.iter().any(|function| function.params.len() == 3)); +} + +fn compile_defaults(source: &str) -> CompiledProgram { + compile(source, standard_host_catalog()).expect("standard catalog compile should succeed") +} + +fn run_defaults(source: &str) -> Vec { + let compiled = compile_defaults(source); + let mut vm = Vm::try_new(compiled.program).expect("test VM construction must not fail"); + let mut registry = HostFunctionRegistry::empty(); + register_jit_builtin_module(&mut registry).expect("production JIT registration"); + registry + .bind_vm_cached(&mut vm) + .expect("standard JIT imports should bind"); + loop { + match vm.run().expect("vm should run") { + VmStatus::Halted => break, + VmStatus::Yielded => continue, + VmStatus::Waiting(_) => panic!("JIT config calls must not pending"), + } + } + vm.stack().to_vec() +} + +#[test] +fn default_compiler_emits_standard_jit_config_imports() { + let compiled = compile_defaults( + r#" + use jit; + let cfg = jit::get_config(); + cfg.enabled; + "#, + ); + let schema = compiled + .program + .host_import_schemas() + .iter() + .flatten() + .find(|schema| schema.name == GET_CONFIG) + .expect("default compiler must not lower jit::get_config as a namespaced builtin"); + assert_eq!( + schema.return_type, + jit_config_type(&standard_host_catalog()) + ); + assert_eq!(schema.fingerprint, standard_host_catalog().fingerprint()); +} + +#[test] +fn default_compiler_accepts_positional_set_config() { + let compiled = compile_defaults("use jit; jit::set_config(true, 3, 64);"); + let schema = compiled + .program + .host_import_schemas() + .iter() + .flatten() + .find(|schema| schema.name == SET_CONFIG) + .expect("positional jit::set_config must be a catalog host import"); + assert_eq!(schema.params.len(), 3); + assert_eq!(schema.fingerprint, standard_host_catalog().fingerprint()); +} + +#[test] +fn standard_registry_resolves_default_jit_imports() { + for source in [ + "use jit; jit::get_config();", + "use jit; jit::set_config(true, 3, 64);", + r#" + use jit; + jit::set_config({ + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 8 + }); + "#, + ] { + let compiled = compile_defaults(source); + let mut vm = Vm::try_new(compiled.program).expect("test VM construction must not fail"); + let mut registry = HostFunctionRegistry::empty(); + register_jit_builtin_module(&mut registry).expect("production JIT registration"); + registry + .bind_vm_cached(&mut vm) + .expect("standard fingerprint registration must bind JIT imports"); + } +} + +#[test] +fn default_vm_installs_typed_jit_config() { + let stack = run_defaults( + r#" + use jit; + let named = jit::set_config({ + enabled: true, + hot_loop_threshold: 5, + max_trace_len: 32 + }); + named.hot_loop_threshold; + "#, + ); + assert_eq!(stack, vec![Value::Int(5)]); +} + +#[test] +fn default_registry_bind_installs_typed_jit_config() { + let compiled = compile_defaults( + r#" + use jit; + let _updated = jit::set_config(false, 7, 16); + let cfg = jit::get_config(); + cfg.max_trace_len; + "#, + ); + let mut vm = Vm::try_new(compiled.program).expect("test VM construction must not fail"); + let mut registry = HostFunctionRegistry::empty(); + register_jit_builtin_module(&mut registry).expect("production JIT registration"); + registry + .bind_vm_cached(&mut vm) + .expect("JIT exact adapters must bind"); + match vm.run().expect("run") { + VmStatus::Halted => {} + other => panic!("expected halt, got {other:?}"), + } + assert_eq!(vm.stack(), &[Value::Int(16)]); +} diff --git a/tests/sqlite_named_struct_tests.rs b/tests/sqlite_named_struct_tests.rs new file mode 100644 index 00000000..94a5c570 --- /dev/null +++ b/tests/sqlite_named_struct_tests.rs @@ -0,0 +1,584 @@ +#![cfg(feature = "sqlite")] +//! SQLite fixed-shape host maps are named structs at the catalog/compiler +//! boundary. Runtime values remain maps; positional params, row cells, and +//! transaction results use named wrappers. + +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; + +use vm::compiler::{ + CompileSourceFileOptions, SourceFlavor, compile_source_with_flavor_and_options, +}; +use vm::host_api::{HostStructField, HostTypeSchema}; +use vm::{ + CompiledProgram, HostFunctionRegistry, SourcePathError, SqliteHostExt, SqlitePolicy, + register_sqlite_builtin_module, sqlite_host_catalog, standard_host_catalog, +}; + +fn opt(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Optional(Box::new(inner)) +} + +fn array(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Array(Box::new(inner)) +} + +fn named(name: &str, fields: Vec) -> HostTypeSchema { + HostTypeSchema::named_struct(name, fields) +} + +fn limits_fields() -> Vec { + [ + "max_connections", + "max_statements", + "max_rows", + "max_columns", + "max_result_bytes", + "max_statement_bytes", + "max_parameters", + "max_parameter_bytes", + "max_pending_operations", + "max_transaction_ms", + "busy_timeout_ms", + ] + .into_iter() + .map(|name| HostStructField::new(name, opt(HostTypeSchema::Int))) + .collect() +} + +fn open_options_fields() -> Vec { + vec![ + HostStructField::new("path", opt(HostTypeSchema::String)), + HostStructField::new("mode", opt(HostTypeSchema::String)), + HostStructField::new("root", opt(HostTypeSchema::String)), + HostStructField::new("limits", opt(named("SqliteLimits", limits_fields()))), + ] +} + +fn execute_result_fields() -> Vec { + vec![ + HostStructField::new("rows_affected", HostTypeSchema::Int), + HostStructField::new("last_insert_rowid", HostTypeSchema::Int), + ] +} + +fn sqlite_value_fields() -> Vec { + vec![ + HostStructField::new("kind", HostTypeSchema::String), + HostStructField::new("int_value", opt(HostTypeSchema::Int)), + HostStructField::new("float_value", opt(HostTypeSchema::Float)), + HostStructField::new("text_value", opt(HostTypeSchema::String)), + HostStructField::new("blob_value", opt(HostTypeSchema::Bytes)), + ] +} + +fn row_fields() -> Vec { + vec![HostStructField::new( + "cells", + array(named("SqliteValue", sqlite_value_fields())), + )] +} + +fn query_result_fields() -> Vec { + vec![ + HostStructField::new("columns", array(HostTypeSchema::String)), + HostStructField::new("rows", array(named("SqliteRow", row_fields()))), + HostStructField::new("truncated", HostTypeSchema::Bool), + HostStructField::new("next_cursor", opt(HostTypeSchema::Int)), + ] +} + +fn statement_fields() -> Vec { + vec![ + HostStructField::new("sql", HostTypeSchema::String), + HostStructField::new( + "params", + opt(array(named("SqliteValue", sqlite_value_fields()))), + ), + HostStructField::new("query", opt(HostTypeSchema::Bool)), + HostStructField::new("limits", opt(named("SqliteLimits", limits_fields()))), + ] +} + +fn transaction_result_fields() -> Vec { + vec![ + HostStructField::new("kind", HostTypeSchema::String), + HostStructField::new( + "execute", + opt(named("SqliteExecuteResult", execute_result_fields())), + ), + HostStructField::new( + "query", + opt(named("SqliteQueryResult", query_result_fields())), + ), + ] +} + +fn compile_with( + source: &str, + catalog: Arc, +) -> Result { + compile_source_with_flavor_and_options( + source, + SourceFlavor::RustScript, + CompileSourceFileOptions::default().with_host_api_catalog(catalog), + ) +} + +fn compile(source: &str) -> Result { + compile_with(source, sqlite_host_catalog()) +} + +fn compile_standard(source: &str) -> Result { + compile_with(source, standard_host_catalog()) +} + +fn compile_err(source: &str) -> String { + match compile(source) { + Ok(_) => panic!("expected compile error for {source}"), + Err(err) => err.to_string(), + } +} + +fn function_named<'a>( + catalog: &'a vm::host_api::HostApiCatalog, + name: &str, +) -> &'a vm::host_api::HostFunctionSchema { + catalog + .functions_named(name) + .into_iter() + .next() + .unwrap_or_else(|| panic!("missing sqlite function {name}")) +} + +fn schema_contains_map(schema: &HostTypeSchema) -> bool { + match schema { + HostTypeSchema::Map(_) | HostTypeSchema::Unknown => true, + HostTypeSchema::Array(inner) | HostTypeSchema::Optional(inner) => { + schema_contains_map(inner) + } + HostTypeSchema::Named { fields, .. } => { + fields.iter().any(|field| schema_contains_map(&field.ty)) + } + HostTypeSchema::Callable { params, result } => { + params.iter().any(schema_contains_map) || schema_contains_map(result) + } + _ => false, + } +} + +#[test] +fn sqlite_catalog_declares_fixed_shape_named_structs() { + let catalog = sqlite_host_catalog(); + for (name, fields) in [ + ("SqliteOpenOptions", open_options_fields()), + ("SqliteLimits", limits_fields()), + ("SqliteExecuteResult", execute_result_fields()), + ("SqliteValue", sqlite_value_fields()), + ("SqliteRow", row_fields()), + ("SqliteQueryResult", query_result_fields()), + ("SqliteStatement", statement_fields()), + ("SqliteTransactionResult", transaction_result_fields()), + ] { + let schema = catalog + .struct_named(name) + .unwrap_or_else(|| panic!("sqlite catalog must declare {name}")); + assert_eq!(schema.fields, fields, "{name} fields"); + } +} + +#[test] +fn sqlite_function_schemas_use_named_structs_not_maps() { + let catalog = sqlite_host_catalog(); + let open = function_named(&catalog, "sqlite::open"); + assert_eq!( + open.params[0].ty, + named("SqliteOpenOptions", open_options_fields()) + ); + + let execute = function_named(&catalog, "sqlite::execute"); + assert_eq!(execute.params[2].name, "params"); + assert_eq!( + execute.params[2].ty, + array(named("SqliteValue", sqlite_value_fields())) + ); + assert_eq!( + execute.return_type, + named("SqliteExecuteResult", execute_result_fields()) + ); + + let query = function_named(&catalog, "sqlite::query"); + assert_eq!(query.params[2].name, "params"); + assert_eq!( + query.params[2].ty, + array(named("SqliteValue", sqlite_value_fields())) + ); + assert_eq!(query.params[3].ty, named("SqliteLimits", limits_fields())); + assert_eq!( + query.return_type, + named("SqliteQueryResult", query_result_fields()) + ); + + let transaction = function_named(&catalog, "sqlite::transaction"); + assert_eq!( + transaction.params[1].ty, + array(named("SqliteStatement", statement_fields())) + ); + assert_eq!( + transaction.return_type, + array(named( + "SqliteTransactionResult", + transaction_result_fields() + )) + ); + + for function in catalog.functions() { + assert!( + !schema_contains_map(&function.return_type) + && function + .params + .iter() + .all(|param| !schema_contains_map(¶m.ty)), + "{} must not keep HostTypeSchema::Map after the named-struct migration", + function.name + ); + } +} + +#[test] +fn standard_catalog_includes_sqlite_named_structs() { + let catalog = standard_host_catalog(); + assert!( + catalog.struct_named("SqliteOpenOptions").is_some(), + "standard catalog merge must copy sqlite named structs" + ); + assert_eq!( + function_named(&catalog, "sqlite::query").return_type, + named("SqliteQueryResult", query_result_fields()) + ); +} + +#[test] +fn object_literal_open_query_and_statement_params_compile() { + compile( + r#" + use sqlite; + let db = sqlite::open({ path: ":memory:", mode: "memory", limits: {} }); + sqlite::execute(&db, "CREATE TABLE t (a INTEGER)", []); + sqlite::query(&db, "SELECT a FROM t", [], { max_rows: 8 }); + sqlite::transaction(&db, [{ sql: "INSERT INTO t VALUES (1)", query: false }]); + sqlite::close(db); + "#, + ) + .expect("object literal sqlite params should compile"); +} + +#[test] +fn empty_open_and_limits_objects_compile() { + compile( + r#" + use sqlite; + let db = sqlite::open({}); + sqlite::query(&db, "SELECT 1", [], {}); + sqlite::close(db); + "#, + ) + .expect("all-optional open/limits objects should compile"); +} + +#[test] +fn extra_field_on_open_options_is_rejected() { + let message = compile_err( + r#" + use sqlite; + sqlite::open({ path: ":memory:", extra: 1 }); + "#, + ); + assert!( + message.contains("sqlite::open") && message.contains("SqliteOpenOptions"), + "extra field diagnostic should name the function and struct, got {message}" + ); + assert!( + message.contains("extra"), + "extra field diagnostic should name the extra key, got {message}" + ); +} + +#[test] +fn extra_field_on_limits_is_rejected() { + let message = compile_err( + r#" + use sqlite; + let db = sqlite::open({ path: ":memory:", mode: "memory" }); + sqlite::query(&db, "SELECT 1", [], { max_rows: 1, nope: 2 }); + "#, + ); + assert!( + message.contains("sqlite::query") && message.contains("SqliteLimits"), + "extra limits field diagnostic should name the function and struct, got {message}" + ); + assert!( + message.contains("nope"), + "extra limits field diagnostic should name the extra key, got {message}" + ); +} + +#[test] +fn missing_sql_on_transaction_statement_is_rejected() { + let message = compile_err( + r#" + use sqlite; + let db = sqlite::open({ path: ":memory:", mode: "memory" }); + sqlite::transaction(&db, [{ params: [] }]); + "#, + ); + assert!( + message.contains("sqlite::transaction") && message.contains("SqliteStatement"), + "missing sql diagnostic should name the function and struct, got {message}" + ); +} + +#[test] +fn wrong_field_type_on_open_path_is_rejected() { + let message = compile_err( + r#" + use sqlite; + sqlite::open({ path: 1 }); + "#, + ); + assert!( + message.contains("sqlite::open") && message.contains("SqliteOpenOptions"), + "wrong path type diagnostic should name the function and struct, got {message}" + ); + assert!( + message.contains("path") && message.contains("int"), + "wrong path type diagnostic should mention path and int, got {message}" + ); +} + +#[test] +fn typed_field_access_on_execute_and_query_results_compiles() { + compile( + r#" + use sqlite; + let db = sqlite::open({ path: ":memory:", mode: "memory", limits: {} }); + let created = sqlite::execute(&db, "CREATE TABLE t (a INTEGER)", []); + let affected = created.rows_affected; + let rowid = created.last_insert_rowid; + let queried = sqlite::query(&db, "SELECT a FROM t", [], {}); + let columns = queried.columns; + let rows = queried.rows; + let truncated = queried.truncated; + sqlite::close(db); + affected + rowid; + "#, + ) + .expect("typed field access on sqlite result structs should compile"); +} + +#[test] +fn typed_params_rows_and_transaction_results_are_structured() { + compile( + r#" + use sqlite; + let db = sqlite::open({ path: ":memory:", mode: "memory" }); + sqlite::execute(&db, "CREATE TABLE t (a INTEGER)", []); + sqlite::execute(&db, "INSERT INTO t VALUES (?)", [{ kind: "int", int_value: 7 }]); + let queried = sqlite::query(&db, "SELECT a FROM t", [], {}); + let first = queried.rows[0].cells[0]; + assert(first.kind == "int"); + assert(first.int_value == 7); + let results = sqlite::transaction(&db, [{ sql: "SELECT a FROM t", query: true }]); + let first_result = results[0]; + assert(first_result.kind == "query"); + assert(first_result.query.rows[0].cells[0].int_value == 7); + sqlite::close(db); + queried.truncated; + "#, + ) + .expect("typed params, rows, and transaction results should be indexable"); +} + +#[test] +fn raw_sqlite_scalar_params_are_rejected() { + let message = compile_err( + r#" + use sqlite; + let db = sqlite::open({ path: ":memory:", mode: "memory" }); + sqlite::execute(&db, "SELECT ?", [7]); + "#, + ); + assert!( + message.contains("sqlite::execute") && message.contains("SqliteValue"), + "raw scalar parameter diagnostic should name the typed value contract, got {message}" + ); +} + +#[test] +fn wrong_nested_sqlite_value_shapes_are_rejected() { + let message = compile_err( + r#" + use sqlite; + let db = sqlite::open({ path: ":memory:", mode: "memory" }); + sqlite::query(&db, "SELECT ?", [{ kind: "int", int_value: "wrong" }], {}); + "#, + ); + assert!( + message.contains("sqlite::query") && message.contains("int_value"), + "wrong nested payload diagnostic should name the typed field, got {message}" + ); +} + +fn noop_waker() -> Waker { + struct LocalNoop; + impl Wake for LocalNoop { + fn wake(self: Arc) {} + } + Waker::from(Arc::new(LocalNoop)) +} + +fn drive_to_halt(vm: &mut vm::vm::Vm) { + loop { + match vm.run() { + Ok(vm::vm::VmStatus::Halted) => return, + Ok(vm::vm::VmStatus::Waiting(_)) => { + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + let mut stuck = 0u64; + loop { + match vm.poll_waiting_host_op(&mut cx) { + Poll::Ready(Ok(())) => break, + Poll::Ready(Err(error)) => panic!("sqlite await failed: {error}"), + Poll::Pending => { + stuck += 1; + assert!(stuck < 100_000, "sqlite await should complete"); + std::thread::yield_now(); + } + } + } + } + Ok(other) => panic!("unexpected vm status {other:?}"), + Err(error) => panic!("sqlite script failed: {error}"), + } + } +} + +fn run_compiled_sqlite(compiled: CompiledProgram) { + let mut vm = vm::vm::Vm::try_new(compiled.program).expect("vm"); + let mut registry = HostFunctionRegistry::empty(); + register_sqlite_builtin_module(&mut registry) + .expect("sqlite exact registration should succeed"); + registry + .bind_vm_cached(&mut vm) + .expect("sqlite exact host imports should bind"); + vm.configure_sqlite(SqlitePolicy::default()); + drive_to_halt(&mut vm); +} + +#[test] +fn runtime_execute_and_query_results_remain_maps_with_typed_fields() { + let compiled = compile_standard( + r#" + use sqlite; + let db = sqlite::open({ path: ":memory:", mode: "memory", limits: {} }); + sqlite::execute(&db, "CREATE TABLE t (a INTEGER)", []); + let inserted = sqlite::execute(&db, "INSERT INTO t VALUES (9)", []); + let queried = sqlite::query(&db, "SELECT a FROM t", [], { max_rows: 10 }); + let affected = inserted.rows_affected; + let rowid = inserted.last_insert_rowid; + let columns = queried.columns; + let rows = queried.rows; + let truncated = queried.truncated; + sqlite::close(db); + affected + rowid; + "#, + ) + .expect("runtime field-access script should compile"); + run_compiled_sqlite(compiled); +} + +#[test] +fn documented_open_modes_compile() { + compile( + r#" + use sqlite; + sqlite::open({ path: ":memory:", mode: "memory" }); + sqlite::open({ path: "state.db", mode: "read_only" }); + sqlite::open({ path: "state.db", mode: "read_write" }); + sqlite::open({ path: "state.db", mode: "read_write_create" }); + sqlite::open({ path: "state.db" }); + "#, + ) + .expect("exact open modes and omitted default should compile"); +} + +#[test] +fn optional_null_fields_compile_as_omitted() { + compile( + r#" + use sqlite; + let db = sqlite::open({ + path: ":memory:", + mode: "memory", + root: null, + limits: null, + }); + sqlite::execute(&db, "CREATE TABLE t (a INTEGER)", []); + sqlite::query(&db, "SELECT a FROM t", [], { max_rows: null }); + sqlite::transaction(&db, [{ + sql: "INSERT INTO t VALUES (1)", + params: null, + query: null, + limits: null, + }]); + sqlite::close(db); + "#, + ) + .expect("present Null on optional sqlite fields should compile"); +} + +#[test] +fn next_cursor_field_access_compiles() { + compile( + r#" + use sqlite; + let db = sqlite::open({ path: ":memory:", mode: "memory", limits: {} }); + sqlite::execute(&db, "CREATE TABLE t (a INTEGER)", []); + let queried = sqlite::query(&db, "SELECT a FROM t", [], {}); + let cursor = queried.next_cursor; + sqlite::close(db); + "#, + ) + .expect("optional next_cursor field access should compile"); +} + +#[test] +fn runtime_null_optional_fields_and_next_cursor_field_access() { + let compiled = compile_standard( + r#" + use sqlite; + let db = sqlite::open({ + path: ":memory:", + mode: "memory", + root: null, + limits: null, + }); + sqlite::execute(&db, "CREATE TABLE t (a INTEGER)", []); + let empty = sqlite::query(&db, "SELECT a FROM t", [], { max_rows: null }); + let empty_cursor = empty.next_cursor; + sqlite::transaction(&db, [{ + sql: "INSERT INTO t VALUES (9)", + params: null, + query: null, + limits: null, + }]); + let queried = sqlite::query(&db, "SELECT a FROM t", [], {}); + let cursor = queried.next_cursor; + sqlite::close(db); + empty_cursor; + cursor; + "#, + ) + .expect("null optional fields and next_cursor access should compile"); + run_compiled_sqlite(compiled); +} diff --git a/tests/support/async_test_bridge.rs b/tests/support/async_test_bridge.rs index afe868fd..1cdb2bf7 100644 --- a/tests/support/async_test_bridge.rs +++ b/tests/support/async_test_bridge.rs @@ -12,8 +12,13 @@ struct TokioTestBridge { impl TokioTestBridge { fn new() -> Self { + #[cfg(not(target_family = "wasm"))] + let mut builder = tokio::runtime::Builder::new_multi_thread(); + #[cfg(target_family = "wasm")] + let mut builder = tokio::runtime::Builder::new_current_thread(); + Self { - runtime: tokio::runtime::Builder::new_multi_thread() + runtime: builder .enable_all() .build() .expect("test runtime should build"), diff --git a/tests/support/vm_reset.rs b/tests/support/vm_reset.rs new file mode 100644 index 00000000..0520b47d --- /dev/null +++ b/tests/support/vm_reset.rs @@ -0,0 +1,45 @@ +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; +use std::thread; +use std::time::{Duration, Instant}; + +use vm::{Vm, VmError}; + +const RESET_TIMEOUT: Duration = Duration::from_secs(5); + +struct ResetWake(thread::Thread); + +impl Wake for ResetWake { + fn wake(self: Arc) { + self.0.unpark(); + } + + fn wake_by_ref(self: &Arc) { + self.0.unpark(); + } +} + +/// Starts a reset and drives its asynchronous close to completion. +pub fn reset_for_reuse_to_ready(vm: &mut Vm) -> Result<(), VmError> { + vm.reset_for_reuse()?; + if !vm.scope_reset_pending() { + return Ok(()); + } + + let deadline = Instant::now() + RESET_TIMEOUT; + let waker = Waker::from(Arc::new(ResetWake(thread::current()))); + let mut cx = Context::from_waker(&waker); + loop { + match vm.poll_reset_for_reuse(&mut cx) { + Poll::Ready(result) => return result, + Poll::Pending => { + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!( + !remaining.is_zero(), + "reset did not reach quiescence in time" + ); + thread::park_timeout(remaining); + } + } + } +} diff --git a/tests/typed_host_no_dynamic_contract_tests.rs b/tests/typed_host_no_dynamic_contract_tests.rs new file mode 100644 index 00000000..3cc7adb3 --- /dev/null +++ b/tests/typed_host_no_dynamic_contract_tests.rs @@ -0,0 +1,225 @@ +use std::any::Any; +use std::collections::BTreeSet; + +#[cfg(all( + feature = "runtime", + feature = "http-client", + not(target_family = "wasm") +))] +use vm::http_host_catalog; +#[cfg(feature = "runtime")] +use vm::sqlite_host_catalog; +#[cfg(feature = "runtime")] +use vm::{HostApiCatalog, jit_host_catalog, standard_host_catalog}; +use vm::{HostStructField, HostTypeSchema}; + +fn assert_no_public_dynamic_root(path: &str, schema: &HostTypeSchema) { + fn visit(path: &str, schema: &HostTypeSchema, seen: &mut BTreeSet) { + match schema { + HostTypeSchema::Map(_) | HostTypeSchema::Unknown => { + panic!("public host schema {path} exposes {schema:?}") + } + HostTypeSchema::Array(inner) => visit(&format!("{path}[]"), inner, seen), + HostTypeSchema::Optional(inner) => visit(&format!("{path}?"), inner, seen), + HostTypeSchema::Named { name, fields } => { + if !seen.insert(name.clone()) { + return; + } + for field in fields { + visit(&format!("{path}.{name}.{}", field.name), &field.ty, seen); + } + } + HostTypeSchema::Callable { params, result } => { + for (index, param) in params.iter().enumerate() { + visit(&format!("{path}.callback_param[{index}]"), param, seen); + } + visit(&format!("{path}.callback_result"), result, seen); + } + HostTypeSchema::Null + | HostTypeSchema::Bool + | HostTypeSchema::Int + | HostTypeSchema::Float + | HostTypeSchema::Number + | HostTypeSchema::String + | HostTypeSchema::Bytes + | HostTypeSchema::Resource(_) => {} + } + } + + visit(path, schema, &mut BTreeSet::new()); +} + +fn panic_text(payload: Box) -> String { + if let Some(message) = payload.downcast_ref::() { + return message.clone(); + } + if let Some(message) = payload.downcast_ref::<&str>() { + return (*message).to_string(); + } + "non-string panic payload".to_string() +} + +fn assert_rejects_dynamic_schema( + path: &str, + schema: HostTypeSchema, + expected_path: &str, + expected_kind: &str, +) { + let payload = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + assert_no_public_dynamic_root(path, &schema); + })) + .expect_err("dynamic schema must be rejected"); + let message = panic_text(payload); + assert!( + message.contains(expected_path), + "diagnostic should identify {expected_path}, got {message}" + ); + assert!( + message.contains(expected_kind), + "diagnostic should identify {expected_kind}, got {message}" + ); +} + +type SchemaWrapper = fn(HostTypeSchema) -> HostTypeSchema; + +fn array_of(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Array(Box::new(inner)) +} + +fn optional_of(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Optional(Box::new(inner)) +} + +fn named_field_of(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::named_struct("Envelope", vec![HostStructField::new("payload", inner)]) +} + +fn callable_param_of(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Callable { + params: vec![inner], + result: Box::new(HostTypeSchema::Int), + } +} + +fn callable_result_of(inner: HostTypeSchema) -> HostTypeSchema { + HostTypeSchema::Callable { + params: vec![HostTypeSchema::Int], + result: Box::new(inner), + } +} + +const NESTED_DYNAMIC_CASES: &[(&str, SchemaWrapper, &str)] = &[ + ("array", array_of, "[]"), + ("optional", optional_of, "?"), + ("named field", named_field_of, ".Envelope.payload"), + ("callable param", callable_param_of, ".callback_param[0]"), + ("callable result", callable_result_of, ".callback_result"), +]; + +#[test] +fn recursive_walker_rejects_nested_dynamic_schemas_with_paths() { + for (kind, dynamic) in [ + ("Map", HostTypeSchema::Map(Box::new(HostTypeSchema::Int))), + ("Unknown", HostTypeSchema::Unknown), + ] { + for (case, wrap, suffix) in NESTED_DYNAMIC_CASES { + let root = format!("nested::{kind}::{case}"); + let expected_path = format!("{root}{suffix}"); + assert_rejects_dynamic_schema(&root, wrap(dynamic.clone()), &expected_path, kind); + } + } +} + +#[cfg(feature = "runtime")] +fn assert_no_public_dynamic_schema(catalog_name: &str, catalog: &HostApiCatalog) { + for schema in catalog.structs() { + for field in &schema.fields { + assert_no_public_dynamic_root( + &format!("{catalog_name}::{}.{}", schema.name, field.name), + &field.ty, + ); + } + } + for function in catalog.functions() { + for param in &function.params { + assert_no_public_dynamic_root( + &format!("{catalog_name}::{}({})", function.name, param.name), + ¶m.ty, + ); + } + assert_no_public_dynamic_root( + &format!("{catalog_name}::{} return", function.name), + &function.return_type, + ); + } +} + +fn recursive_named_schema() -> HostTypeSchema { + HostTypeSchema::named_struct( + "RecursiveNode", + vec![ + HostStructField::new("value", HostTypeSchema::Int), + HostStructField::new( + "next", + HostTypeSchema::Optional(Box::new(HostTypeSchema::named_struct( + "RecursiveNode", + Vec::new(), + ))), + ), + ], + ) +} + +#[test] +fn recursive_named_struct_walk_stops_at_repeated_named_type() { + assert_no_public_dynamic_root("recursive::node", &recursive_named_schema()); +} + +#[cfg(feature = "runtime")] +#[test] +fn affected_public_host_catalogs_have_no_reachable_map_or_unknown() { + assert_no_public_dynamic_schema("jit", &jit_host_catalog()); + assert_no_public_dynamic_schema("standard", &standard_host_catalog()); + #[cfg(all(feature = "http-client", not(target_family = "wasm")))] + assert_no_public_dynamic_schema("http", &http_host_catalog()); + #[cfg(all(feature = "sqlite", not(target_arch = "wasm32")))] + assert_no_public_dynamic_schema("sqlite", &sqlite_host_catalog()); +} + +#[cfg(all(feature = "runtime", not(feature = "sqlite")))] +#[test] +fn standard_catalog_keeps_sqlite_editor_schema_without_sqlite_runtime() { + let sqlite = sqlite_host_catalog(); + assert!( + sqlite.function("sqlite::query").is_some(), + "the standalone editor/compiler catalog must retain SQLite declarations" + ); + let query = sqlite + .function("sqlite::query") + .expect("SQLite query declaration"); + let value = sqlite + .struct_named("SqliteValue") + .expect("SQLite value named struct"); + assert_eq!( + query.params[2].ty, + HostTypeSchema::Array(Box::new(value.as_type())), + "SQLite query params must stay typed without the runtime feature" + ); + assert_no_public_dynamic_schema("sqlite", &sqlite); + + let catalog = standard_host_catalog(); + assert!( + catalog.function("sqlite::open").is_some(), + "the editor/compiler catalog must retain SQLite schema declarations" + ); + assert!( + catalog.struct_named("SqliteOpenOptions").is_some(), + "the editor/compiler catalog must retain SQLite named structs" + ); + assert!( + vm::default_host_callables() + .iter() + .all(|callable| !callable.name.starts_with("sqlite::")), + "the executable default host surface must remain feature-gated" + ); +} diff --git a/tests/vm/http_host_tests.rs b/tests/vm/http_host_tests.rs new file mode 100644 index 00000000..6c204a01 --- /dev/null +++ b/tests/vm/http_host_tests.rs @@ -0,0 +1,1421 @@ +#![cfg(all(feature = "http-client", not(target_family = "wasm")))] + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::mpsc; +use std::task::{Context, Poll}; +use std::thread; +use std::time::{Duration, Instant}; + +use vm::{ + CallOutcome, CallReturn, HostAsyncBridge, HostFunctionRegistry, HostFuture, HostFutureOutput, + HostOpId, HttpConfig, HttpHostExt, Program, Value, Vm, VmError, VmResult, VmStatus, + compile_source, +}; + +#[derive(Default)] +struct TokioHostDriver { + submitted: HashMap, +} + +impl HostAsyncBridge for TokioHostDriver { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + self.submitted.insert(op_id, future); + Ok(()) + } + + fn poll_op(&mut self, op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(VmError::HostError(format!( + "unknown external host operation {op_id}" + )))) + } + + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + let poll = self.submitted.get_mut(&op_id).map_or_else( + || { + Poll::Ready(Err(VmError::HostError(format!( + "unknown submitted host operation {op_id}" + )))) + }, + |future| future.as_mut().poll(cx), + ); + if poll.is_ready() { + self.submitted.remove(&op_id); + } + poll + } + + fn cancel_op(&mut self, op_id: HostOpId) { + self.submitted.remove(&op_id); + } +} + +fn install_host_driver(vm: &mut Vm) { + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); +} + +fn build_request_program(url: String) -> Program { + compile_source(&format!( + r#" + use http; + http::client::request({{"method": "GET", "url": "{url}"}}); + "# + )) + .expect("HTTP request source should compile") + .program +} + +fn build_request_program_with_method(url: &str, method: &str) -> Program { + compile_source(&format!( + r#" + use http; + http::client::request({{"method": "{method}", "url": "{url}", "body": {{ kind: "text", text: "payload" }}}}); + "# + )) + .expect("HTTP request source with method should compile") + .program +} + +fn build_request_program_with_headers(url: &str, method: &str) -> Program { + compile_source(&format!( + r#" + use http; + http::client::request({{"method": "{method}", "url": "{url}", "body": {{ kind: "text", text: "payload" }}, "headers": [ + {{ name: "Authorization", value: "Bearer secret" }}, + {{ name: "Proxy-Authorization", value: "Basic proxy-secret" }}, + {{ name: "Cookie", value: "a=b" }}, + {{ name: "X-Api-Key", value: "api-secret" }}, + {{ name: "X-Arbitrary", value: "custom-secret" }}, + {{ name: "Content-Type", value: "application/body" }}, + {{ name: "Accept", value: "application/json" }}, + {{ name: "Accept-Language", value: "en-US" }}, + {{ name: "Accept-Encoding", value: "identity" }} + ]}}); + "# + )) + .expect("HTTP request source with headers should compile") + .program +} + +const TEST_IO_TIMEOUT: Duration = Duration::from_secs(5); + +fn bind_test_listener() -> TcpListener { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener should bind"); + listener + .set_nonblocking(true) + .expect("test listener should be nonblocking"); + listener +} + +fn accept_test_connection( + listener: &TcpListener, +) -> std::io::Result<(TcpStream, std::net::SocketAddr)> { + let deadline = Instant::now() + TEST_IO_TIMEOUT; + loop { + match listener.accept() { + Ok((stream, address)) => { + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(TEST_IO_TIMEOUT))?; + stream.set_write_timeout(Some(TEST_IO_TIMEOUT))?; + return Ok((stream, address)); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "test server accept timed out", + )); + } + thread::sleep(Duration::from_millis(1)); + } + Err(error) => return Err(error), + } + } +} + +fn local_http_config(port: u16) -> HttpConfig { + HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![port], + allow_private_ips: true, + ..HttpConfig::default() + } +} + +fn spawn_test_server() -> (u16, thread::JoinHandle<()>) { + let listener = bind_test_listener(); + let port = listener + .local_addr() + .expect("test listener should have an address") + .port(); + let handle = thread::spawn(move || { + let (mut stream, _) = + accept_test_connection(&listener).expect("test request should arrive"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = stream + .read(&mut buffer) + .expect("request should be readable"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + assert!(request.starts_with(b"GET / HTTP/1.1")); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nX-Test: yes\r\n\r\nok") + .expect("response should be writable"); + }); + (port, handle) +} + +fn spawn_response_server(response: Vec) -> (u16, thread::JoinHandle<()>) { + let listener = bind_test_listener(); + let port = listener + .local_addr() + .expect("response listener should have an address") + .port(); + let handle = thread::spawn(move || { + let (mut stream, _) = + accept_test_connection(&listener).expect("response request should arrive"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream + .read(&mut buffer) + .expect("response request should be readable"); + assert!(read > 0, "request ended before its headers"); + request.extend_from_slice(&buffer[..read]); + } + let _ = stream.write_all(&response); + }); + (port, handle) +} + +fn spawn_recording_response_server( + response: Vec, +) -> (u16, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = bind_test_listener(); + let port = listener + .local_addr() + .expect("recording listener should have an address") + .port(); + let (sender, receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + let (mut stream, _) = + accept_test_connection(&listener).expect("recording request should arrive"); + let request = read_recorded_request(&mut stream); + sender + .send(request) + .expect("recorded request receiver should remain open"); + stream + .write_all(&response) + .expect("recorded response should be writable"); + }); + (port, receiver, handle) +} + +fn response_head_with_size(size: usize) -> Vec { + let prefix = b"HTTP/1.1 204 No Content\r\nX-Pad: "; + let suffix = b"\r\n\r\n"; + let value_len = size + .checked_sub(prefix.len() + suffix.len()) + .expect("response-head test size should fit its framing"); + let mut response = Vec::with_capacity(size); + response.extend_from_slice(prefix); + response.extend(std::iter::repeat_n(b'a', value_len)); + response.extend_from_slice(suffix); + assert_eq!(response.len(), size); + response +} + +fn spawn_redirect_server( + status: u16, + redirects: usize, +) -> (u16, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = bind_test_listener(); + let port = listener + .local_addr() + .expect("redirect listener should have an address") + .port(); + let (sender, receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + for index in 0..=redirects { + let (mut stream, _) = + accept_test_connection(&listener).expect("redirect request should arrive"); + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + stream + .read_exact(&mut byte) + .expect("redirect request headers should be readable"); + request.push(byte[0]); + } + let head = String::from_utf8(request).expect("request should be valid UTF-8"); + let content_length = head + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("valid content length")) + }) + }) + .unwrap_or(0); + let mut body = vec![0; content_length]; + stream + .read_exact(&mut body) + .expect("redirect request body should be readable"); + sender + .send(format!("{head}{}", String::from_utf8_lossy(&body))) + .expect("request should be recorded"); + if index < redirects { + let location = if index + 1 == redirects { + format!("http://127.0.0.1:{port}/final") + } else { + format!("http://127.0.0.1:{port}/hop/{index}") + }; + write!( + stream, + "HTTP/1.1 {status} Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\n\r\n" + ) + .expect("redirect response should be writable"); + } else { + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .expect("final response should be writable"); + } + } + }); + (port, receiver, handle) +} + +fn read_recorded_request(stream: &mut std::net::TcpStream) -> String { + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + stream + .read_exact(&mut byte) + .expect("request headers should be readable"); + request.push(byte[0]); + } + let head = String::from_utf8(request).expect("request should be valid UTF-8"); + let content_length = head + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("valid content length")) + }) + }) + .unwrap_or(0); + let mut body = vec![0; content_length]; + stream + .read_exact(&mut body) + .expect("request body should be readable"); + format!("{head}{}", String::from_utf8_lossy(&body)) +} + +fn header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> { + request + .split("\r\n") + .skip(1) + .filter_map(|line| line.split_once(':')) + .find_map(|(header_name, value)| { + header_name + .eq_ignore_ascii_case(name) + .then_some(value.trim()) + }) +} + +fn header_values<'a>(request: &'a str, name: &str) -> Vec<&'a str> { + request + .split("\r\n") + .skip(1) + .filter_map(|line| line.split_once(':')) + .filter_map(|(header_name, value)| { + header_name + .eq_ignore_ascii_case(name) + .then_some(value.trim()) + }) + .collect() +} + +fn has_header(request: &str, name: &str) -> bool { + header_value(request, name).is_some() +} + +fn request_line(request: &str) -> &str { + request.split_once("\r\n").map_or(request, |(line, _)| line) +} + +fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { + haystack + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +fn spawn_cross_origin_redirect_servers( + status: u16, +) -> ( + u16, + u16, + mpsc::Receiver, + mpsc::Receiver, + thread::JoinHandle<()>, + thread::JoinHandle<()>, +) { + let target_listener = bind_test_listener(); + let target_port = target_listener + .local_addr() + .expect("target should have an address") + .port(); + let source_listener = bind_test_listener(); + let source_port = source_listener + .local_addr() + .expect("source should have an address") + .port(); + let (source_sender, source_requests) = mpsc::channel(); + let (target_sender, target_requests) = mpsc::channel(); + let source_handle = thread::spawn(move || { + let (mut stream, _) = + accept_test_connection(&source_listener).expect("source request should arrive"); + let request = read_recorded_request(&mut stream); + source_sender.send(request).expect("source request record"); + write!( + stream, + "HTTP/1.1 {status} Redirect\r\nLocation: http://127.0.0.1:{target_port}/final\r\nContent-Length: 0\r\n\r\n" + ) + .expect("redirect response should be writable"); + }); + let target_handle = thread::spawn(move || { + let (mut stream, _) = + accept_test_connection(&target_listener).expect("target request should arrive"); + let request = read_recorded_request(&mut stream); + target_sender.send(request).expect("target request record"); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .expect("final response should be writable"); + }); + ( + source_port, + target_port, + source_requests, + target_requests, + source_handle, + target_handle, + ) +} + +fn response_field<'a>(value: &'a Value, key: &str) -> &'a Value { + let Value::Map(map) = value else { + panic!("expected response map, got {value:?}"); + }; + map.get(&Value::string(key)) + .unwrap_or_else(|| panic!("response missing field {key}")) +} + +async fn drive_vm_to_halt(vm: &mut Vm) -> Result<(), vm::VmError> { + let mut status = vm.run()?; + loop { + match status { + VmStatus::Halted => return Ok(()), + VmStatus::Yielded => status = vm.resume()?, + VmStatus::Waiting(_) => { + vm.await_waiting_host_op().await?; + status = vm.resume()?; + } + } + } +} + +async fn run_raw_response(response: Vec, mut config: HttpConfig) -> Result { + let (port, server) = spawn_response_server(response); + config.allowed_schemes = vec!["http".to_string()]; + config.allowed_hosts = vec!["127.0.0.1".to_string()]; + config.allowed_ports = vec![port]; + config.allow_private_ips = true; + let mut vm = Vm::new(build_request_program(format!("http://127.0.0.1:{port}/"))); + vm.configure_http(config) + .expect("raw-response HTTP configuration should be valid"); + install_host_driver(&mut vm); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + let outcome = drive_vm_to_halt(&mut vm).await; + server.join().expect("raw response server should finish"); + outcome.map(|()| vm.stack()[0].clone()) +} + +#[tokio::test(flavor = "current_thread")] +async fn http_host_executes_a_bounded_request_and_returns_a_response_map() { + let (port, server) = spawn_test_server(); + let mut vm = Vm::new(build_request_program(format!("http://127.0.0.1:{port}/"))); + vm.configure_http(local_http_config(port)) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + + drive_vm_to_halt(&mut vm) + .await + .expect("http request should complete"); + server.join().expect("test server should finish"); + + assert_eq!(response_field(&vm.stack()[0], "status"), &Value::Int(200)); + assert_eq!( + response_field(&vm.stack()[0], "body"), + &Value::bytes(b"ok".to_vec()) + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn buffered_response_headers_use_canonical_order_duplicates_and_raw_bytes() { + let response = run_raw_response( + b"HTTP/1.1 200 OK\r\nX-Repeat: first\r\nX-Repeat: second\r\nX-Raw: \x80\r\nContent-Length: 0\r\n\r\n".to_vec(), + HttpConfig::default(), + ) + .await + .expect("typed response headers should decode"); + let Value::Array(headers) = response_field(&response, "headers") else { + panic!("expected typed response header array"); + }; + assert_eq!(headers.len(), 4); + assert_eq!( + response_field(&headers[0], "name"), + &Value::string("content-length") + ); + assert_eq!(response_field(&headers[1], "name"), &Value::string("x-raw")); + assert_eq!( + response_field(response_field(&headers[1], "value"), "kind"), + &Value::string("bytes") + ); + assert_eq!( + response_field(response_field(&headers[1], "value"), "text"), + &Value::Null + ); + assert_eq!( + response_field(response_field(&headers[1], "value"), "bytes"), + &Value::bytes(vec![0x80]) + ); + assert_eq!( + response_field(&headers[2], "name"), + &Value::string("x-repeat") + ); + assert_eq!( + response_field(response_field(&headers[2], "value"), "kind"), + &Value::string("text") + ); + assert_eq!( + response_field(response_field(&headers[2], "value"), "text"), + &Value::string("first") + ); + assert_eq!( + response_field(response_field(&headers[2], "value"), "bytes"), + &Value::Null + ); + assert_eq!( + response_field(&headers[3], "name"), + &Value::string("x-repeat") + ); + assert_eq!( + response_field(response_field(&headers[3], "value"), "text"), + &Value::string("second") + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn buffered_request_preserves_duplicate_header_order() { + let (port, requests, server) = + spawn_recording_response_server(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok".to_vec()); + let source = format!( + r#" + use http; + http::client::request({{ + method: "GET", + url: "http://127.0.0.1:{port}/", + headers: [ + {{ name: "x-order", value: "first" }}, + {{ name: "x-order", value: "second" }}, + {{ name: "x-order", value: "third" }} + ] + }}); + "# + ); + let compiled = compile_source(&source).expect("duplicate headers should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_http(local_http_config(port)) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + + drive_vm_to_halt(&mut vm) + .await + .expect("duplicate-header request should complete"); + let request = requests.recv().expect("request should be recorded"); + assert_eq!( + header_values(&request, "x-order"), + ["first", "second", "third"] + ); + assert_eq!( + response_field(&vm.stack()[0], "body"), + &Value::bytes(b"ok".to_vec()) + ); + server.join().expect("recording server should finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn buffered_redirect_rewrites_only_post_for_301_and_302() { + for status in [301, 302] { + for method in ["POST", "PUT", "PATCH", "DELETE", "OPTIONS"] { + let (port, requests, server) = spawn_redirect_server(status, 1); + let mut vm = Vm::new(build_request_program_with_method( + &format!("http://127.0.0.1:{port}/start"), + method, + )); + vm.configure_http(local_http_config(port)) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + + drive_vm_to_halt(&mut vm) + .await + .expect("redirected request should complete"); + assert_eq!(response_field(&vm.stack()[0], "status"), &Value::Int(200)); + + let first = requests.recv().expect("initial request should be recorded"); + let second = requests + .recv() + .expect("redirected request should be recorded"); + assert!( + request_line(&first).starts_with(&format!("{method} /start HTTP/1.1")), + "status {status}, method {method}: " + ); + let expected_method = if method == "POST" { "GET" } else { method }; + assert!( + request_line(&second).starts_with(&format!("{expected_method} /final HTTP/1.1")), + "status {status}, method {method}: " + ); + if method == "POST" { + assert!(!second.ends_with("payload"), "status {status}: "); + } else { + assert!(second.ends_with("payload"), "status {status}: "); + } + server.join().expect("redirect server should finish"); + } + } +} + +#[tokio::test(flavor = "current_thread")] +async fn buffered_cross_origin_redirect_strips_credentials_and_custom_headers() { + for status in [301, 302, 303, 307, 308] { + let ( + source_port, + target_port, + source_requests, + target_requests, + source_server, + target_server, + ) = spawn_cross_origin_redirect_servers(status); + let mut http_config = local_http_config(source_port); + http_config.allowed_ports.push(target_port); + let mut vm = Vm::new(build_request_program_with_headers( + &format!("http://127.0.0.1:{source_port}/start"), + "POST", + )); + vm.configure_http(http_config) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + + drive_vm_to_halt(&mut vm) + .await + .expect("cross-origin redirect should complete"); + assert_eq!(response_field(&vm.stack()[0], "status"), &Value::Int(200)); + + let first = source_requests + .recv() + .expect("initial request should be recorded"); + assert_eq!(header_value(&first, "authorization"), Some("Bearer secret")); + assert_eq!( + header_value(&first, "proxy-authorization"), + Some("Basic proxy-secret") + ); + assert_eq!(header_value(&first, "cookie"), Some("a=b")); + assert_eq!(header_value(&first, "x-api-key"), Some("api-secret")); + assert_eq!(header_value(&first, "x-arbitrary"), Some("custom-secret")); + + let second = target_requests + .recv() + .expect("redirected request should be recorded"); + let expected_method = if status == 307 || status == 308 { + "POST" + } else { + "GET" + }; + assert!( + request_line(&second).starts_with(&format!("{expected_method} /final HTTP/1.1")), + "status {status}: " + ); + let expected_host = format!("127.0.0.1:{target_port}"); + assert_eq!( + header_value(&second, "host"), + Some(expected_host.as_str()), + "status {status}: authority was not rebuilt" + ); + assert_eq!(header_value(&second, "transfer-encoding"), None); + if expected_method == "POST" { + assert!(second.ends_with("payload"), "status {status}: "); + } else { + assert!(!second.ends_with("payload"), "status {status}: "); + assert_eq!(header_value(&second, "content-type"), None); + assert_eq!(header_value(&second, "transfer-encoding"), None); + assert!( + header_value(&second, "content-length").is_none_or(|value| value == "0"), + "status {status}: stale content length in " + ); + } + for forbidden in [ + "authorization", + "proxy-authorization", + "cookie", + "x-api-key", + "x-arbitrary", + ] { + assert!( + !has_header(&second, forbidden), + "status {status}, forbidden {forbidden}: " + ); + } + for safe in ["accept", "accept-language", "accept-encoding"] { + assert!( + has_header(&second, safe), + "status {status}, safe {safe}: " + ); + } + source_server + .join() + .expect("source redirect server should finish"); + target_server.join().expect("target server should finish"); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn buffered_same_origin_redirect_preserves_caller_header_values() { + for status in [301, 302, 303, 307, 308] { + let (port, requests, server) = spawn_redirect_server(status, 1); + let mut vm = Vm::new(build_request_program_with_headers( + &format!("http://127.0.0.1:{port}/start"), + "POST", + )); + vm.configure_http(local_http_config(port)) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + drive_vm_to_halt(&mut vm) + .await + .expect("same-origin redirect should complete"); + + let first = requests.recv().expect("initial request should be recorded"); + let second = requests + .recv() + .expect("redirected request should be recorded"); + assert_eq!(header_value(&first, "authorization"), Some("Bearer secret")); + assert_eq!( + header_value(&first, "proxy-authorization"), + Some("Basic proxy-secret") + ); + assert_eq!(header_value(&first, "cookie"), Some("a=b")); + assert_eq!(header_value(&first, "x-api-key"), Some("api-secret")); + assert_eq!(header_value(&first, "x-arbitrary"), Some("custom-secret")); + + for name in [ + "authorization", + "proxy-authorization", + "cookie", + "x-api-key", + "x-arbitrary", + ] { + assert_eq!( + header_value(&second, name), + header_value(&first, name), + "status {status}, header {name}" + ); + } + let rewrites = status == 301 || status == 302 || status == 303; + assert_eq!( + header_value(&second, "content-type").is_some(), + !rewrites, + "status {status}: stale body header in " + ); + server + .join() + .expect("same-origin redirect server should finish"); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn buffered_response_limits_reject_adversarial_framing_and_exact_head_overflow() { + let mut oversized_trailers = + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nok\r\n0\r\n".to_vec(); + for index in 0..70 { + oversized_trailers + .extend_from_slice(format!("X-Trailer-{index}: {}\r\n", "a".repeat(1024)).as_bytes()); + } + oversized_trailers.extend_from_slice(b"\r\n"); + + let cases = [ + ( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n" + .to_vec(), + "response body", + ), + ( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n" + .to_vec(), + "http", + ), + ( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nZ\r\nnope\r\n0\r\n\r\n".to_vec(), + "http", + ), + ( + b"HTTP/1.1 200 OK\r\nContent-Length: nope\r\n\r\n".to_vec(), + "http", + ), + ( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nContent-Length: 3\r\n\r\nabc".to_vec(), + "http", + ), + ( + b"HTTP/1.1 200 OK\r\nBroken-Header\r\nContent-Length: 0\r\n\r\n".to_vec(), + "http", + ), + (oversized_trailers, "response"), + ]; + for (response, expected) in cases { + let config = HttpConfig { + max_response_body_bytes: 4, + ..HttpConfig::default() + }; + let error = run_raw_response(response, config) + .await + .expect_err("adversarial response must be rejected"); + assert!( + contains_ascii_case_insensitive(&error.to_string(), expected), + "expected {expected} error, got {error}" + ); + } + + let exact = run_raw_response(response_head_with_size(64 * 1024), HttpConfig::default()) + .await + .expect("a response head at the exact limit should be accepted"); + assert_eq!(response_field(&exact, "status"), &Value::Int(204)); + + let error = run_raw_response( + response_head_with_size(64 * 1024 + 1), + HttpConfig::default(), + ) + .await + .expect_err("a response head over the limit must be rejected"); + assert!( + error.to_string().contains("response head") || error.to_string().contains("connection"), + "unexpected oversized-head error: {error}" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn buffered_redirect_chain_reaches_final_body() { + let (port, requests, server) = spawn_redirect_server(307, 2); + let mut vm = Vm::new(build_request_program(format!( + "http://127.0.0.1:{port}/start" + ))); + vm.configure_http(local_http_config(port)) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + + drive_vm_to_halt(&mut vm) + .await + .expect("redirect chain should complete"); + assert_eq!(response_field(&vm.stack()[0], "status"), &Value::Int(200)); + assert_eq!( + response_field(&vm.stack()[0], "body"), + &Value::bytes(b"ok".to_vec()) + ); + for _ in 0..3 { + requests + .recv() + .expect("each redirect request should be recorded"); + } + server.join().expect("redirect server should finish"); +} + +#[test] +fn http_host_rejects_targets_until_an_explicit_policy_allows_them() { + let mut vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + let error = vm + .run() + .expect_err("unconfigured HTTP targets must be rejected"); + assert!( + error.to_string().contains("HTTP host is not configured") + || error + .to_string() + .contains("HTTP target host is not allowed"), + "unexpected error: {error}" + ); +} + +#[test] +fn empty_registry_keeps_language_builtins_but_rejects_http_capability() { + let mut language_vm = Vm::new( + vm::compile_source("assert(true);") + .expect("language builtin program should compile") + .program, + ); + HostFunctionRegistry::empty() + .bind_vm_cached(&mut language_vm) + .expect("empty registry should bind a program without host imports"); + assert_eq!( + language_vm.run().expect("language builtin should run"), + VmStatus::Halted + ); + + let mut http_vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + let error = HostFunctionRegistry::restricted() + .bind_vm_cached(&mut http_vm) + .expect_err("unapproved HTTP capability must fail during preflight"); + assert!(error.to_string().contains("http::client::request")); +} + +#[test] +fn restricted_registry_requires_explicit_namespaced_builtin_capability() { + let compiled = compile_source( + r#"use io; +io::open("/tmp/rustscript-capability-test", "r");"#, + ) + .expect("namespaced host builtin should compile"); + let mut vm = Vm::new(compiled.program); + let error = HostFunctionRegistry::restricted() + .bind_vm_cached(&mut vm) + .expect_err("ungranted namespaced builtin must fail during preflight"); + assert!(error.to_string().contains("io_open")); +} + +#[test] +fn capability_binding_plan_cannot_cross_registry_profiles() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let unrestricted = HostFunctionRegistry::new(); + let plan = unrestricted + .prepare_plan_with_schemas(&program.imports, program.host_import_schemas()) + .expect("unrestricted registry should prepare HTTP plan"); + let mut vm = Vm::new(program); + let error = HostFunctionRegistry::restricted() + .bind_vm_with_plan(&mut vm, &plan) + .expect_err("capability plan must not cross registry profiles"); + assert!(error.to_string().contains("different capability profile")); +} + +#[test] +fn capability_binding_plan_cannot_outlive_registry_mutation() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let mut registry = HostFunctionRegistry::new(); + let plan = registry + .prepare_plan_with_schemas(&program.imports, program.host_import_schemas()) + .expect("registry should prepare HTTP plan"); + registry + .allow_builtin("io::open") + .expect("io capability should be a known builtin"); + let mut vm = Vm::new(program); + let error = registry + .bind_vm_with_plan(&mut vm, &plan) + .expect_err("stale capability plan must not bind"); + assert!(error.to_string().contains("different capability profile")); +} + +#[test] +fn capability_binding_plan_detects_divergent_registry_clone_mutations() { + let unchanged_program = build_request_program("http://127.0.0.1:1/".to_string()); + let mut unchanged_registry = HostFunctionRegistry::restricted(); + unchanged_registry + .allow_builtin("http::client::request") + .expect("HTTP capability should be known"); + let unchanged_plan = unchanged_registry + .prepare_plan_with_schemas( + &unchanged_program.imports, + unchanged_program.host_import_schemas(), + ) + .expect("restricted registry should prepare HTTP plan"); + let unchanged_clone = unchanged_registry.clone(); + let mut unchanged_vm = Vm::new(unchanged_program); + unchanged_clone + .bind_vm_with_plan(&mut unchanged_vm, &unchanged_plan) + .expect("an unchanged registry clone should reuse the plan"); + + let branch_program = build_request_program("http://127.0.0.1:1/".to_string()); + let branch_registry = HostFunctionRegistry::restricted(); + let mut first_mutation = branch_registry.clone(); + let mut second_mutation = branch_registry; + first_mutation + .allow_builtin("http::client::request") + .expect("HTTP capability should be known"); + second_mutation + .allow_builtin("io::open") + .expect("io capability should be known"); + let plan = first_mutation + .prepare_plan_with_schemas( + &branch_program.imports, + branch_program.host_import_schemas(), + ) + .expect("first capability branch should prepare HTTP plan"); + let mut mutated_vm = Vm::new(branch_program); + let error = second_mutation + .bind_vm_with_plan(&mut mutated_vm, &plan) + .expect_err("divergent capability branches must reject each other's plan"); + assert!(error.to_string().contains("different capability profile")); +} + +#[test] +fn registry_state_rejects_structural_sibling_mutations() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let registry = HostFunctionRegistry::new(); + let mut source = registry.clone(); + let destination = registry; + source.register_static_args("test::structural", 0, |_args| { + Ok(CallOutcome::Return(CallReturn::One(Value::Null))) + }); + let plan = source + .prepare_plan_with_schemas(&program.imports, program.host_import_schemas()) + .expect("mutated source registry should prepare HTTP plan"); + let mut vm = Vm::new(program); + let error = destination + .bind_vm_with_plan(&mut vm, &plan) + .expect_err("structural sibling mutation must reject the plan"); + assert!(error.to_string().contains("different registry state")); +} + +#[test] +fn cached_plan_refreshes_after_a_sibling_registry_mutation() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let registry = HostFunctionRegistry::new(); + let mut mutating_sibling = registry.clone(); + let destination = registry; + + let mut priming_vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + destination + .bind_vm_cached(&mut priming_vm) + .expect("destination should prime its plan cache"); + mutating_sibling.register_static_args("test::cache_refresh", 0, |_args| { + Ok(CallOutcome::Return(CallReturn::One(Value::Null))) + }); + + let mut refreshed_vm = Vm::new(program); + destination + .bind_vm_cached(&mut refreshed_vm) + .expect("destination should rebuild a plan after sibling mutation"); +} + +#[tokio::test(flavor = "current_thread")] +async fn max_stream_duration_does_not_shorten_buffered_requests() { + let listener = bind_test_listener(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = accept_test_connection(&listener).unwrap(); + let mut request = [0; 1024]; + assert!(socket.read(&mut request).unwrap() > 0); + thread::sleep(std::time::Duration::from_millis(30)); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .unwrap(); + }); + let mut vm = Vm::new(build_request_program(format!("http://127.0.0.1:{port}/"))); + let mut buffered_config = local_http_config(port); + buffered_config.max_stream_duration = std::time::Duration::from_millis(1); + buffered_config.request_timeout = std::time::Duration::from_millis(200); + vm.configure_http(buffered_config).unwrap(); + install_host_driver(&mut vm); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + drive_vm_to_halt(&mut vm).await.unwrap(); + assert_eq!(response_field(&vm.stack()[0], "status"), &Value::Int(200)); + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn explicitly_allowed_http_capability_reaches_http_policy() { + let mut vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + vm.configure_http(HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![1], + allow_private_ips: true, + ..HttpConfig::default() + }) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + let mut registry = HostFunctionRegistry::restricted(); + registry + .allow_builtin("http::client::request") + .expect("HTTP builtin should be explicitly allowlisted"); + registry + .bind_vm_cached(&mut vm) + .expect("explicit capability plan should bind"); + let error = drive_vm_to_halt(&mut vm) + .await + .expect_err("connection failure should reach HTTP runtime"); + assert!(!matches!(error, vm::VmError::UnboundImport(_))); +} + +#[test] +fn http_in_flight_limit_rejects_before_starting_a_request() { + let mut vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + vm.set_http_max_in_flight(0); + vm.configure_http(HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![1], + allow_private_ips: true, + + ..HttpConfig::default() + }) + .expect("HTTP configuration should be valid"); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + let error = vm + .run() + .expect_err("zero in-flight capacity must reject the request"); + assert!(error.to_string().contains("in-flight request limit")); +} + +#[test] +fn http_config_accepts_bounded_stream_defaults_and_rejects_zero_bounds() { + let defaults = HttpConfig::default(); + defaults + .validate() + .expect("default HTTP stream bounds should be valid"); + assert!(defaults.max_stream_item_bytes > 0); + assert!(defaults.max_stream_total_bytes > 0); + assert!(defaults.max_sse_line_bytes > 0); + assert_eq!( + defaults.max_stream_duration, + std::time::Duration::from_secs(5 * 60) + ); + assert!(!defaults.stream_idle_timeout.is_zero()); + + HttpConfig { + max_stream_duration: std::time::Duration::from_millis(1), + ..defaults.clone() + } + .validate() + .expect("an explicit positive stream duration should be valid"); + + let invalid = [ + HttpConfig { + max_stream_item_bytes: 0, + ..defaults.clone() + }, + HttpConfig { + max_stream_total_bytes: 0, + ..defaults.clone() + }, + HttpConfig { + max_sse_line_bytes: 0, + ..defaults.clone() + }, + HttpConfig { + max_stream_duration: std::time::Duration::ZERO, + ..defaults.clone() + }, + HttpConfig { + stream_idle_timeout: std::time::Duration::ZERO, + ..defaults.clone() + }, + ]; + for config in invalid { + assert!(config.validate().is_err(), "zero stream bound must fail"); + } + + let mut vm = Vm::new(Program::new(Vec::new(), Vec::new())); + let error = vm + .configure_http(HttpConfig { + max_stream_item_bytes: 0, + ..HttpConfig::default() + }) + .expect_err("configuration must reject a zero stream bound"); + assert!(error.to_string().contains("max_stream_item_bytes")); + assert!(!vm.http_is_configured()); +} + +#[test] +fn http_config_rejects_request_timeout_that_cannot_form_a_deadline() { + let invalid = HttpConfig { + request_timeout: std::time::Duration::MAX, + ..HttpConfig::default() + }; + let validation_error = invalid + .validate() + .expect_err("overflowing request timeout must be rejected"); + assert!(validation_error.to_string().contains("request_timeout")); + + let mut vm = Vm::new(Program::new(Vec::new(), Vec::new())); + let configure_error = vm + .configure_http(invalid) + .expect_err("configuration must reject an overflowing request timeout"); + assert!(configure_error.to_string().contains("request_timeout")); + assert!(!vm.http_is_configured()); + + let invalid = HttpConfig { + max_stream_duration: std::time::Duration::MAX, + ..HttpConfig::default() + }; + let validation_error = invalid + .validate() + .expect_err("overflowing stream duration must be rejected"); + assert!(validation_error.to_string().contains("max_stream_duration")); + + let mut vm = Vm::new(Program::new(Vec::new(), Vec::new())); + let configure_error = vm + .configure_http(invalid) + .expect_err("configuration must reject an overflowing stream duration"); + assert!(configure_error.to_string().contains("max_stream_duration")); + assert!(!vm.http_is_configured()); +} + +fn spawn_pending_server() -> (u16, mpsc::Receiver<()>, thread::JoinHandle<()>) { + let listener = bind_test_listener(); + let port = listener + .local_addr() + .expect("pending listener should have an address") + .port(); + let (ready_sender, ready_receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + let (mut stream, _) = + accept_test_connection(&listener).expect("pending request should arrive"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = stream + .read(&mut buffer) + .expect("pending request should be readable"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + ready_sender + .send(()) + .expect("pending request readiness should be observed"); + while stream.read(&mut buffer).unwrap_or(0) != 0 {} + }); + (port, ready_receiver, handle) +} + +fn spawn_pending_then_response_server() -> (u16, mpsc::Receiver<()>, thread::JoinHandle<()>) { + let listener = bind_test_listener(); + let port = listener + .local_addr() + .expect("pending listener should have an address") + .port(); + let (ready_sender, ready_receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + let (mut pending, _) = + accept_test_connection(&listener).expect("pending request should arrive"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = pending + .read(&mut buffer) + .expect("pending request should be readable"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + ready_sender + .send(()) + .expect("pending request readiness should be observed"); + while pending + .read(&mut buffer) + .expect("pending connection should remain readable") + != 0 + {} + + let (mut response, _) = + accept_test_connection(&listener).expect("replacement request should arrive"); + let mut request = Vec::new(); + loop { + let read = response + .read(&mut buffer) + .expect("replacement request should be readable"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + response + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .expect("replacement response should be writable"); + }); + (port, ready_receiver, handle) +} + +async fn reset_and_wait(vm: &mut Vm) -> Result<(), vm::VmError> { + vm.reset_for_reuse()?; + std::future::poll_fn(|cx| vm.poll_reset_for_reuse(cx)).await +} + +#[tokio::test(flavor = "current_thread")] +async fn reset_retires_buffered_http_future_and_releases_its_permit() { + let (port, ready, server) = spawn_pending_then_response_server(); + let mut vm = Vm::new(build_request_program(format!("http://127.0.0.1:{port}/"))); + vm.set_http_max_in_flight(1); + vm.configure_http(local_http_config(port)) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + assert!(matches!(vm.run(), Ok(VmStatus::Waiting(_)))); + ready + .recv() + .expect("first request should reach the transport"); + + reset_and_wait(&mut vm) + .await + .expect("reset should retire the pending HTTP operation"); + assert!( + vm.is_reusable(), + "reset must wait for HTTP worker quiescence" + ); + + assert!(matches!(vm.run(), Ok(VmStatus::Waiting(_)))); + drive_vm_to_halt(&mut vm) + .await + .expect("replacement request should acquire the released permit"); + assert_eq!(response_field(&vm.stack()[0], "status"), &Value::Int(200)); + server.join().expect("pending server should finish"); +} + +#[test] +fn shutdown_and_drop_retire_buffered_http_futures() { + for shutdown in [true, false] { + let (port, ready, server) = spawn_pending_server(); + let mut vm = Vm::new(build_request_program(format!("http://127.0.0.1:{port}/"))); + vm.set_http_max_in_flight(1); + vm.configure_http(local_http_config(port)) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + assert!(matches!(vm.run(), Ok(VmStatus::Waiting(_)))); + ready + .recv() + .expect("request should reach the transport before teardown"); + if shutdown { + vm.shutdown(); + } + drop(vm); + server.join().expect("teardown should close the transport"); + } +} + +/// HttpConfig and the max-in-flight limit are persistent module state: both +/// survive `reset_for_reuse`, and `clear_http_configuration` removes only the +/// configuration while the max-in-flight policy (and any live scope +/// admission) remains in force. +#[test] +fn http_config_and_max_policy_are_persistent_while_clear_removes_only_config() { + let mut vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + vm.set_http_max_in_flight(2); + vm.configure_http(local_http_config(1)) + .expect("HTTP config should be valid"); + assert_eq!(vm.http_max_in_flight(), 2); + assert!(vm.http_is_configured()); + + // Reset retires only scope runtime state; persistent policy survives. + vm.reset_for_reuse() + .expect("reset should complete for an idle VM"); + assert_eq!( + vm.http_max_in_flight(), + 2, + "max-in-flight policy must survive reset" + ); + assert!(vm.http_is_configured(), "HTTP config must survive reset"); + + // clear removes only the config; the max-in-flight policy remains. + vm.clear_http_configuration(); + assert!(!vm.http_is_configured()); + assert_eq!( + vm.http_max_in_flight(), + 2, + "clear_http_configuration must not reset the max-in-flight policy" + ); +} + +/// `set_http_max_in_flight` updates the persistent policy but must not eagerly +/// create scope runtime state: it only touches the live scope admission when a +/// request has already declared it. A lazily created admission must observe +/// the *current* persistent max at capture time. +#[test] +fn set_max_in_flight_updates_policy_without_eagerly_creating_runtime_state() { + #[derive(Debug)] + struct Probe; + + let mut vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + // No scope runtime state exists yet: set_http_max_in_flight must not + // eagerly create any arena state or ordinary resource. + vm.set_http_max_in_flight(5); + assert_eq!(vm.execution_scope().resources().len(), 0); + assert!( + vm.host_context().scope_state::().is_none(), + "the scope-state arena must stay empty until a request declares state" + ); + + // A lazily created admission reads the current persistent max: with max 0 + // the first request is rejected before any connection is attempted. + vm.set_http_max_in_flight(0); + vm.configure_http(local_http_config(1)) + .expect("HTTP config should be valid"); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + let error = vm + .run() + .expect_err("zero in-flight capacity must reject the request"); + assert!(error.to_string().contains("in-flight request limit")); +} diff --git a/tests/vm/http_sse_tests.rs b/tests/vm/http_sse_tests.rs new file mode 100644 index 00000000..63008a00 --- /dev/null +++ b/tests/vm/http_sse_tests.rs @@ -0,0 +1,1623 @@ +#![cfg(all(feature = "http-client", not(target_family = "wasm")))] + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::mpsc; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use std::task::{Context, Poll}; +use std::thread; +use std::time::{Duration, Instant}; + +use vm::operation::OperationCancelReason; +use vm::{ + CallOutcome, CallReturn, HostAsyncBridge, HostFunctionRegistry, HostFuture, HostFutureOutput, + HostOpId, HostStackFunction, HttpConfig, HttpHostExt, Value, Vm, VmError, VmMap, VmResult, + VmStatus, compile_source, +}; + +#[derive(Default)] +struct TokioHostDriver { + submitted: HashMap, +} + +impl HostAsyncBridge for TokioHostDriver { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + self.submitted.insert(op_id, future); + Ok(()) + } + + fn poll_op(&mut self, op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(VmError::HostError(format!( + "unknown external host operation {op_id}" + )))) + } + + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + self.submitted.get_mut(&op_id).map_or_else( + || { + Poll::Ready(Err(VmError::HostError(format!( + "unknown submitted host operation {op_id}" + )))) + }, + |future| future.as_mut().poll(cx), + ) + } + + fn cancel_op(&mut self, op_id: HostOpId) { + self.submitted.remove(&op_id); + } + + fn request_cancel_op( + &mut self, + op_id: HostOpId, + _reason: OperationCancelReason, + ) -> VmResult<()> { + self.cancel_op(op_id); + Ok(()) + } + + fn poll_cancel_op(&mut self, _op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +struct AsyncWaitOnce { + calls: Arc, +} + +struct CountCalls { + calls: Arc, +} + +impl HostStackFunction for CountCalls { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(CallOutcome::Return(CallReturn::one(Value::Bool(true)))) + } +} + +struct InspectOpenHeaders; + +impl HostStackFunction for InspectOpenHeaders { + fn call(&mut self, _vm: &mut Vm, args: &[Value]) -> VmResult { + let [event] = args else { + return Err(VmError::HostError( + "open-header inspector expected one event".to_string(), + )); + }; + let valid = if field(event, "kind") == &Value::string("open") + && field(event, "status") == &Value::Int(200) + && field(event, "url") != &Value::Null + && field(event, "event") == &Value::Null + && field(event, "data") == &Value::Null + && field(event, "id") == &Value::Null + && field(event, "retry_ms") == &Value::Null + { + match field(event, "headers") { + Value::Array(headers) if headers.len() == 5 => { + field(&headers[0], "name") == &Value::string("content-length") + && field(&headers[1], "name") == &Value::string("content-type") + && field(&headers[2], "name") == &Value::string("x-duplicate") + && field(&headers[3], "name") == &Value::string("x-duplicate") + && field(&headers[4], "name") == &Value::string("x-raw") + && field(field(&headers[2], "value"), "kind") == &Value::string("text") + && field(field(&headers[2], "value"), "text") == &Value::string("first") + && field(field(&headers[2], "value"), "bytes") == &Value::Null + && field(field(&headers[3], "value"), "text") == &Value::string("second") + && field(field(&headers[3], "value"), "bytes") == &Value::Null + && field(field(&headers[4], "value"), "kind") == &Value::string("bytes") + && field(field(&headers[4], "value"), "text") == &Value::Null + && field(field(&headers[4], "value"), "bytes") == &Value::bytes(vec![0x80]) + } + _ => false, + } + } else { + false + }; + Ok(CallOutcome::Return(CallReturn::one(Value::Bool(valid)))) + } +} + +impl HostStackFunction for AsyncWaitOnce { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> VmResult { + if self.calls.fetch_add(1, Ordering::SeqCst) == 0 { + vm.submit_host_future(Box::pin(async move { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + Ok(HostFutureOutput::returning(CallReturn::one(Value::Bool( + true, + )))) + })) + } else { + Ok(CallOutcome::Return(CallReturn::one(Value::Bool(true)))) + } + } +} + +fn field<'a>(value: &'a Value, key: &str) -> &'a Value { + let Value::Map(map) = value else { + panic!("expected map, got {value:?}"); + }; + map.get(&Value::string(key)) + .unwrap_or_else(|| panic!("missing field {key}")) +} + +fn map(entries: impl IntoIterator) -> Value { + Value::Map(Arc::new(VmMap::from_entries( + entries + .into_iter() + .map(|(key, value)| (Value::string(key), value)) + .collect(), + ))) +} + +async fn drive(vm: &mut Vm) -> VmResult<()> { + let mut status = vm.run()?; + loop { + match status { + VmStatus::Halted => return Ok(()), + VmStatus::Yielded => status = vm.resume()?, + VmStatus::Waiting(_) => { + vm.await_waiting_host_op().await?; + status = vm.resume()?; + } + } + } +} + +async fn reset_and_wait(vm: &mut Vm) -> VmResult<()> { + vm.reset_for_reuse()?; + std::future::poll_fn(|cx| vm.poll_reset_for_reuse(cx)).await +} + +async fn run_sse_source(source: &str, config: HttpConfig) -> Result { + let compiled = compile_source(source).expect("SSE source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_http(config).unwrap(); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + drive(&mut vm).await.map(|()| vm) +} + +struct ServerHandle { + shutdown: Option>, + handle: Option>, +} + +const TEST_IO_TIMEOUT: Duration = Duration::from_secs(5); + +fn bind_test_listener() -> TcpListener { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + listener +} + +fn configure_test_stream(stream: &TcpStream) -> std::io::Result<()> { + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(TEST_IO_TIMEOUT))?; + stream.set_write_timeout(Some(TEST_IO_TIMEOUT))?; + Ok(()) +} + +fn accept_test_connection(listener: &TcpListener) -> std::io::Result<(TcpStream, SocketAddr)> { + let deadline = Instant::now() + TEST_IO_TIMEOUT; + loop { + match listener.accept() { + Ok((stream, address)) => { + configure_test_stream(&stream)?; + return Ok((stream, address)); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "test server accept timed out", + )); + } + thread::sleep(Duration::from_millis(1)); + } + Err(error) => return Err(error), + } + } +} + +impl ServerHandle { + fn join(mut self) -> thread::Result<()> { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + self.handle + .take() + .expect("test server thread handle") + .join() + } +} + +fn wait_for_test_timeout(duration: std::time::Duration) { + let (_wake, wake_rx) = mpsc::channel::<()>(); + let _ = wake_rx.recv_timeout(duration); +} + +fn accept_or_shutdown( + listener: &TcpListener, + shutdown: &mpsc::Receiver<()>, +) -> std::io::Result> { + let deadline = Instant::now() + TEST_IO_TIMEOUT; + loop { + match listener.accept() { + Ok((stream, address)) => { + configure_test_stream(&stream)?; + return Ok(Some((stream, address))); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "test server accept timed out", + )); + } + match shutdown.recv_timeout(std::time::Duration::from_millis(10)) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(None), + Err(mpsc::RecvTimeoutError::Timeout) => {} + } + } + Err(error) => return Err(error), + } + } +} + +fn server(response_parts: Vec<&'static [u8]>) -> (u16, ServerHandle) { + let listener = bind_test_listener(); + let addr = listener.local_addr().unwrap(); + let (shutdown, shutdown_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + let Some((mut stream, _)) = accept_or_shutdown(&listener, &shutdown_rx).unwrap() else { + return; + }; + stream.set_nonblocking(false).unwrap(); + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]); + assert_eq!(request_line(&request), "GET /events HTTP/1.1"); + assert_eq!(header_value(&request, "accept"), Some("text/event-stream")); + for part in response_parts { + if stream.write_all(part).is_err() || stream.flush().is_err() { + break; + } + } + }); + ( + addr.port(), + ServerHandle { + shutdown: Some(shutdown), + handle: Some(handle), + }, + ) +} + +fn recording_server( + responses: Vec>, +) -> (u16, mpsc::Receiver, ServerHandle) { + let listener = bind_test_listener(); + let addr = listener.local_addr().unwrap(); + let (sender, receiver) = mpsc::channel(); + let (shutdown, shutdown_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + for response_parts in responses { + 'connection: loop { + let Some((mut stream, _)) = accept_or_shutdown(&listener, &shutdown_rx).unwrap() + else { + return; + }; + stream.set_nonblocking(false).unwrap(); + + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + match stream.read_exact(&mut byte) { + Ok(()) => request.push(byte[0]), + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => { + continue 'connection; + } + Err(error) => panic!("failed to read test request: {error}"), + } + } + let head = String::from_utf8(request).unwrap(); + + let content_length = head + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + }) + .unwrap_or(0); + let mut body = vec![0; content_length]; + match stream.read_exact(&mut body) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => { + continue 'connection; + } + Err(error) => panic!("failed to read test request body: {error}"), + } + sender + .send(format!("{head}{}", String::from_utf8_lossy(&body))) + .unwrap(); + for part in response_parts { + if stream.write_all(part).is_err() || stream.flush().is_err() { + break; + } + } + break 'connection; + } + } + }); + ( + addr.port(), + receiver, + ServerHandle { + shutdown: Some(shutdown), + handle: Some(handle), + }, + ) +} + +fn header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> { + request + .split("\r\n") + .skip(1) + .filter_map(|line| line.split_once(':')) + .find_map(|(header_name, value)| { + header_name + .eq_ignore_ascii_case(name) + .then_some(value.trim()) + }) +} + +fn request_line(request: &str) -> &str { + request.split_once("\r\n").map_or(request, |(line, _)| line) +} + +fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool { + haystack + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +fn has_header(request: &str, name: &str) -> bool { + request + .split("\r\n") + .skip(1) + .filter_map(|line| line.split_once(':')) + .any(|(header_name, _)| header_name.eq_ignore_ascii_case(name)) +} + +fn config(port: u16) -> HttpConfig { + HttpConfig { + allowed_schemes: vec!["http".into()], + allowed_hosts: vec!["127.0.0.1".into()], + allowed_ports: vec![port], + allow_private_ips: true, + ..HttpConfig::default() + } +} + +fn assert_no_connection(listener: TcpListener, context: &'static str) -> ServerHandle { + listener.set_nonblocking(true).unwrap(); + let (shutdown, shutdown_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + if accept_or_shutdown(&listener, &shutdown_rx) + .unwrap() + .is_some() + { + panic!("{context} must be rejected before a second connection"); + } + }); + ServerHandle { + shutdown: Some(shutdown), + handle: Some(handle), + } +} + +fn rejecting_redirect_server( + location: impl FnOnce(u16) -> String + Send + 'static, +) -> (u16, mpsc::Receiver, ServerHandle) { + let listener = bind_test_listener(); + let addr = listener.local_addr().unwrap(); + let location = location(addr.port()); + let (sender, receiver) = mpsc::channel(); + let (shutdown, shutdown_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + let Some((mut stream, _)) = accept_or_shutdown(&listener, &shutdown_rx).unwrap() else { + return; + }; + stream.set_nonblocking(false).unwrap(); + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + sender.send(String::from_utf8(request).unwrap()).unwrap(); + write!( + stream, + "HTTP/1.1 307 Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\n\r\n" + ) + .unwrap(); + drop(stream); + if accept_or_shutdown(&listener, &shutdown_rx) + .unwrap() + .is_some() + { + panic!("invalid redirect must be rejected before a second connection"); + } + }); + ( + addr.port(), + receiver, + ServerHandle { + shutdown: Some(shutdown), + handle: Some(handle), + }, + ) +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_delivers_open_events_end_and_terminal_summary() { + let (port, server) = server(vec![ + b"HTTP/1.1 200 OK\r\nContent-Type: Text/Event-Stream; charset=utf-8\r\nTransfer-Encoding: chunked\r\n\r\n", + b"b\r\ndata: one\n\n\r\n", + b"18\r\nevent: named\ndata: two\n\n\r\n", + b"0\r\n\r\n", + ]); + let source = format!( + r#" + use http; + fn record(item: SseEvent) -> SseCallbackAction {{ + if item.kind == "open" && item.status != 200 {{ let _ = 1 / 0; }} + if item.kind == "event" && item.data == "one" && item.event != null {{ let _ = 1 / 0; }} + if item.kind == "event" && item.data == "two" && item.event != "named" {{ let _ = 1 / 0; }} + if item.kind == "end" && item.status != null {{ let _ = 1 / 0; }} + {{action: "continue"}} + }} + let result = http::client::sse( + {{"method": "GET", "url": "http://127.0.0.1:{port}/events"}}, + record + ); + result; + "# + ); + let compiled = compile_source(&source).expect("SSE source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_http(config(port)).unwrap(); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + + drive(&mut vm).await.unwrap(); + server.join().unwrap(); + + let result = &vm.stack()[0]; + assert_eq!(field(result, "outcome"), &Value::string("eof")); + assert_eq!(field(result, "status"), &Value::Int(200)); + assert_eq!(field(result, "items"), &Value::Int(4)); + assert_eq!(field(result, "bytes_sent"), &Value::Int(0)); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_callback_inspects_typed_open_headers() { + let (port, server) = server(vec![ + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nX-Duplicate: first\r\nX-Duplicate: second\r\nX-Raw: \x80\r\nContent-Length: 0\r\n\r\n", + ]); + let source = format!( + r#" + use http; + fn inspect_event(item: SseEvent) -> bool; + fn inspect(item: SseEvent) -> SseCallbackAction {{ + {{ + action: if inspect_event(item) => {{ "stop" }} else => {{ "continue" }} + }} + }} + let result = http::client::sse( + {{ method: "GET", url: "http://127.0.0.1:{port}/events" }}, + inspect + ); + result; + "# + ); + let compiled = compile_source(&source).expect("SSE source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_http(config(port)).unwrap(); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + let mut registry = HostFunctionRegistry::new(); + registry.register_stack("inspect_event", 1, || Box::new(InspectOpenHeaders)); + registry.bind_vm_cached(&mut vm).unwrap(); + drive(&mut vm).await.expect("SSE request"); + server.join().expect("SSE server should finish"); + assert_eq!(field(&vm.stack()[0], "outcome"), &Value::string("stopped")); + assert_eq!(field(&vm.stack()[0], "items"), &Value::Int(1)); +} + +#[test] +fn sse_rejects_wrong_callback_schema_and_invalid_timeout_before_permit_admission() { + assert!(compile_source( + r#"use http; http::client::sse({"method":"GET","url":"http://127.0.0.1:1/"}, |item| 1);"# + ) + .is_err()); + + for (timeout, expected) in [("0", "positive"), ("-1", "positive")] { + let source = format!( + r#" + use http; + fn callback(item: SseEvent) -> SseCallbackAction {{ {{action: "continue"}} }} + http::client::sse( + {{method: "GET", url: "http://127.0.0.1:1/events", timeout_ms: {timeout}}}, + callback + ); + "# + ); + let compiled = compile_source(&source).unwrap(); + let mut vm = Vm::new(compiled.program); + vm.set_http_max_in_flight(0); + vm.configure_http(config(1)).unwrap(); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + let error = vm.run().unwrap_err(); + assert!(error.to_string().contains(expected), "{timeout}: {error}"); + assert!( + !error.to_string().contains("in-flight request limit"), + "timeout validation must precede permit admission: {error}" + ); + } + + assert!( + compile_source( + r#" + use http; + fn callback(item: SseEvent) -> SseCallbackAction { {action: "continue"} } + http::client::sse( + {method: "GET", url: "http://127.0.0.1:1/events", timeout_ms: "1"}, + callback + ); + "# + ) + .is_err(), + "string timeout_ms must be rejected at compile time" + ); + + let source = r#" + use http; + fn callback(item: SseEvent) -> SseCallbackAction { {action: "continue"} } + http::client::sse( + {method: "GET", url: "http://127.0.0.1:1/events", timeout_ms: 1}, + callback + ); + "#; + let compiled = compile_source(source).unwrap(); + let mut vm = Vm::new(compiled.program); + vm.set_http_max_in_flight(0); + vm.configure_http(config(1)).unwrap(); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + let error = vm.run().unwrap_err(); + assert!( + error.to_string().contains("in-flight request limit"), + "a positive timeout should pass timeout admission: {error}" + ); + + let source = r#" + use http; + fn callback(item: SseEvent) -> SseCallbackAction { {action: "continue"} } + http::client::sse( + {method: "PUT", url: "http://127.0.0.1:1/events"}, + callback + ); + "#; + let compiled = compile_source(source).unwrap(); + let mut vm = Vm::new(compiled.program); + vm.configure_http(config(1)).unwrap(); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + let error = vm.run().unwrap_err(); + assert!(error.to_string().contains("GET or POST"), "{error}"); +} + +#[test] +fn sse_admission_does_not_require_a_tokio_reactor() { + let source = r#" + use http; + fn callback(item: SseEvent) -> SseCallbackAction { {action: "continue"} } + http::client::sse( + {method: "GET", url: "http://127.0.0.1:1/events"}, + callback + ); + "#; + let compiled = compile_source(source).unwrap(); + let mut vm = Vm::new(compiled.program); + vm.configure_http(config(1)).unwrap(); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + vm.reset_for_reuse().expect("SSE reset should complete"); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_accepts_post_with_body() { + let (port, requests, server) = recording_server(vec![vec![ + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\n\r\n", + ]]); + let source = format!( + r#" + use http; + fn callback(item: SseEvent) -> SseCallbackAction {{ {{action: "continue"}} }} + http::client::sse( + {{method: "POST", url: "http://127.0.0.1:{port}/events", body: {{ kind: "text", text: "payload" }}}}, + callback + ); + "# + ); + let mut vm = run_sse_source(&source, config(port)).await.unwrap(); + assert_eq!(vm.host_context().resource_count(), 0); + assert_eq!(vm.host_context().operation_count(), 0); + assert_eq!(field(&vm.stack()[0], "outcome"), &Value::string("eof")); + let request = requests.recv().unwrap(); + assert_eq!(request_line(&request), "POST /events HTTP/1.1"); + assert!(request.ends_with("payload")); + server.join().unwrap(); +} + +fn redirect_server(status: u16) -> (u16, mpsc::Receiver, ServerHandle) { + let listener = bind_test_listener(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + listener.set_nonblocking(true).unwrap(); + let (sender, receiver) = mpsc::channel(); + let (shutdown, shutdown_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + for index in 0..2 { + let Some((mut stream, _)) = accept_or_shutdown(&listener, &shutdown_rx).unwrap() else { + return; + }; + let mut request = Vec::new(); + let mut byte = [0_u8; 1]; + while !request.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + let head = String::from_utf8(request).unwrap(); + let length = head + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + }) + .unwrap_or(0); + let mut body = vec![0; length]; + stream.read_exact(&mut body).unwrap(); + sender + .send(format!("{head}{}", String::from_utf8_lossy(&body))) + .unwrap(); + if index == 0 { + write!( + stream, + "HTTP/1.1 {status} Redirect\r\nLocation: http://127.0.0.1:{port}/final\r\nContent-Length: 0\r\n\r\n" + ) + .unwrap(); + } else { + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\n\r\n") + .unwrap(); + } + } + }); + ( + addr.port(), + receiver, + ServerHandle { + shutdown: Some(shutdown), + handle: Some(handle), + }, + ) +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_post_redirect_method_and_body_follow_http_rules() { + for (status, preserves_post) in [ + (301, false), + (302, false), + (303, false), + (307, true), + (308, true), + ] { + let (port, requests, server) = redirect_server(status); + let source = format!( + r#"use http; + fn callback(item: SseEvent) -> SseCallbackAction {{ {{action: "continue"}} }} + http::client::sse({{method:"POST", url:"http://127.0.0.1:{port}/start", body:{{ kind:"text", text:"payload" }}}}, callback);"# + ); + run_sse_source(&source, config(port)).await.unwrap(); + let first = requests.recv().unwrap(); + let second = requests.recv().unwrap(); + assert_eq!(request_line(&first), "POST /start HTTP/1.1"); + if preserves_post { + assert!( + request_line(&second) == "POST /final HTTP/1.1", + "status {status}: " + ); + assert!(second.ends_with("payload"), "status {status}: "); + } else { + assert!( + request_line(&second) == "GET /final HTTP/1.1", + "status {status}: " + ); + assert!(!second.ends_with("payload"), "status {status}: "); + } + server.join().unwrap(); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_get_redirect_preserves_get_for_301_and_302() { + for status in [301, 302] { + let (port, requests, server) = redirect_server(status); + let source = format!( + r#"use http; + fn callback(item: SseEvent) -> SseCallbackAction {{ {{action: "continue"}} }} + http::client::sse({{method:"GET", url:"http://127.0.0.1:{port}/start"}}, callback);"# + ); + run_sse_source(&source, config(port)).await.unwrap(); + let first = requests.recv().unwrap(); + let second = requests.recv().unwrap(); + assert_eq!(request_line(&first), "GET /start HTTP/1.1"); + assert!( + request_line(&second) == "GET /final HTTP/1.1", + "status {status}: " + ); + assert!(!second.ends_with("payload"), "status {status}: "); + server.join().unwrap(); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_rejects_redirect_userinfo_before_reconnecting() { + let (port, requests, server) = rejecting_redirect_server(|port| { + format!("http://redirect-user:redirect-password@127.0.0.1:{port}/final") + }); + let source = format!( + r#"use http; + fn callback(item: SseEvent) -> SseCallbackAction {{ {{action: "continue"}} }} + http::client::sse( + {{method:"GET", url:"http://127.0.0.1:{port}/start", headers:[{{name:"Authorization", value:"Bearer secret"}}, {{name:"Cookie", value:"a=b"}}]}}, + callback + );"# + ); + let error = match run_sse_source(&source, config(port)).await { + Ok(_) => panic!("redirect userinfo must be rejected"), + Err(error) => error, + }; + assert!( + error.to_string().contains("URL userinfo is not allowed"), + "{error}" + ); + let request = requests.recv().unwrap(); + assert_eq!( + header_value(&request, "authorization"), + Some("Bearer secret") + ); + assert_eq!(header_value(&request, "cookie"), Some("a=b")); + assert!(!request.contains("redirect-user")); + assert!(!request.contains("redirect-password")); + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_rejects_disallowed_redirect_targets_before_connecting() { + for (host, allow_target_port, expected) in [ + ("127.0.0.1", false, "target port"), + ("localhost", true, "target host"), + ] { + let target_listener = bind_test_listener(); + let target_port = target_listener.local_addr().unwrap().port(); + let no_target_connection = assert_no_connection(target_listener, expected); + let location = format!("http://{host}:{target_port}/final"); + let redirect = format!( + "HTTP/1.1 307 Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\n\r\n" + ); + let redirect = Box::leak(redirect.into_bytes().into_boxed_slice()); + let (source_port, requests, source_server) = recording_server(vec![vec![redirect]]); + let source = format!( + r#"use http; + fn callback(item: SseEvent) -> SseCallbackAction {{ {{action: "continue"}} }} + http::client::sse( + {{method:"GET", url:"http://127.0.0.1:{source_port}/start", headers:[{{name:"Authorization", value:"Bearer secret"}}, {{name:"Cookie", value:"a=b"}}]}}, + callback + );"# + ); + let mut allowed = config(source_port); + if allow_target_port { + allowed.allowed_ports.push(target_port); + } + let error = match run_sse_source(&source, allowed).await { + Ok(_) => panic!("disallowed redirect target must be rejected"), + Err(error) => error, + }; + assert!(error.to_string().contains(expected), "{error}"); + let request = requests.recv().unwrap(); + assert_eq!( + header_value(&request, "authorization"), + Some("Bearer secret") + ); + assert_eq!(header_value(&request, "cookie"), Some("a=b")); + source_server.join().unwrap(); + no_target_connection.join().unwrap(); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_stop_retires_without_end_and_returns_stopped_summary() { + let (port, server) = server(vec![ + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n", + b"9\r\ndata: x\n\n\r\n", + b"0\r\n\r\n", + ]); + let source = format!( + r#"use http; + fn stop(item: SseEvent) -> SseCallbackAction {{ {{action: "stop"}} }} + http::client::sse({{"method":"GET","url":"http://127.0.0.1:{port}/events"}}, stop);"# + ); + let vm = run_sse_source(&source, config(port)).await.unwrap(); + server.join().unwrap(); + assert_eq!(field(&vm.stack()[0], "outcome"), &Value::string("stopped")); + assert_eq!(field(&vm.stack()[0], "items"), &Value::Int(1)); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_rejected_nested_admission_rolls_back_before_reset_reuse() { + let (port, _requests, server) = recording_server(vec![ + vec![ + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n", + b"9\r\ndata: x\n\n\r\n", + b"0\r\n\r\n", + ], + vec![b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\n\r\n"], + vec![ + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n", + b"9\r\ndata: x\n\n\r\n", + b"0\r\n\r\n", + ], + vec![b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\n\r\n"], + vec![ + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n", + b"9\r\ndata: x\n\n\r\n", + b"0\r\n\r\n", + ], + vec![b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\n\r\n"], + ]); + let source = format!( + r#" + use http; + fn inner(item: SseEvent) -> SseCallbackAction {{ {{action: "continue"}} }} + fn outer(item: SseEvent) -> SseCallbackAction {{ + http::client::sse( + {{method: "GET", url: "http://127.0.0.1:{port}/inner"}}, + inner + ); + {{action: "continue"}} + }} + http::client::sse( + {{method: "GET", url: "http://127.0.0.1:{port}/outer"}}, + outer + ); + "# + ); + let compiled = compile_source(&source).unwrap(); + let mut vm = Vm::new(compiled.program); + vm.set_http_max_in_flight(2); + vm.configure_http(config(port)).unwrap(); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + + for _ in 0..3 { + let error = drive(&mut vm) + .await + .expect_err("nested SSE must be rejected"); + assert!( + error + .to_string() + .contains("vm already owns an active callable stream"), + "{error}" + ); + let cleanup_result = std::future::poll_fn(|cx| vm.poll_waiting_host_op(cx)).await; + if let Err(cleanup_error) = cleanup_result { + assert!( + cleanup_error + .to_string() + .contains("vm already owns an active callable stream"), + "{cleanup_error}" + ); + } + assert_eq!(vm.host_context().resource_count(), 0); + assert_eq!(vm.host_context().operation_count(), 0); + reset_and_wait(&mut vm) + .await + .expect("rejected nested SSE must drain before reset reuse"); + assert_eq!(vm.host_context().resource_count(), 0); + assert_eq!(vm.host_context().operation_count(), 0); + assert!(vm.is_reusable()); + } + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_reset_releases_the_connection_permit_before_reuse() { + let (port, requests, server) = recording_server(vec![ + vec![b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\n\r\n"], + vec![b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\n\r\n"], + ]); + let source = format!( + r#"use http; + http::client::sse( + {{"method":"GET","url":"http://127.0.0.1:{port}/events"}}, + |item| {{action: "continue"}} + );"# + ); + let compiled = compile_source(&source).unwrap(); + let mut vm = Vm::new(compiled.program); + vm.set_http_max_in_flight(1); + vm.configure_http(config(port)).unwrap(); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + assert!( + requests + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("initial stream request should be recorded") + .starts_with("GET /events HTTP/1.1") + ); + reset_and_wait(&mut vm) + .await + .expect("SSE reset should complete"); + drive(&mut vm).await.unwrap(); + assert_eq!(field(&vm.stack()[0], "outcome"), &Value::string("eof")); + assert!( + requests + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("reused stream request should be recorded") + .starts_with("GET /events HTTP/1.1") + ); + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_reset_while_callback_waits_retires_stream_to_quiescence() { + let (port, _requests, server) = recording_server(vec![ + vec![ + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n", + b"5\r\ndata: x\n\n\r\n", + b"0\r\n\r\n", + ], + vec![b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\n\r\n"], + ]); + let source = format!( + r#"use http; + fn async_wait() -> bool; + http::client::sse( + {{"method":"GET","url":"http://127.0.0.1:{port}/events"}}, + |item| {{ + action: if async_wait() => {{ "continue" }} else => {{ "continue" }} + }} + );"# + ); + let compiled = compile_source(&source).unwrap(); + let mut vm = Vm::new(compiled.program); + vm.set_http_max_in_flight(1); + vm.configure_http(config(port)).unwrap(); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + let wait_calls = Arc::new(AtomicUsize::new(0)); + let mut registry = HostFunctionRegistry::new(); + registry.register_stack("async_wait", 0, { + let wait_calls = Arc::clone(&wait_calls); + move || { + Box::new(AsyncWaitOnce { + calls: Arc::clone(&wait_calls), + }) + } + }); + registry.bind_vm_cached(&mut vm).unwrap(); + + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + vm.await_waiting_host_op().await.unwrap(); + assert!(matches!(vm.resume().unwrap(), VmStatus::Waiting(_))); + assert_eq!(wait_calls.load(Ordering::SeqCst), 1); + + reset_and_wait(&mut vm) + .await + .expect("reset must cancel callback and retire the stream"); + drive(&mut vm) + .await + .expect("the reused VM must reacquire the permit"); + assert_eq!(wait_calls.load(Ordering::SeqCst), 3); + assert_eq!(field(&vm.stack()[0], "outcome"), &Value::string("eof")); + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_rejects_status_content_type_and_idle_peer() { + for (head, expected) in [ + (b"HTTP/1.1 404 Not Found\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\n\r\n".as_slice(), "status 404"), + (b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 0\r\n\r\n".as_slice(), "Content-Type"), + ] { + let (port, server) = server(vec![head]); + let source = format!( + r#"use http; fn go(item: SseEvent) -> SseCallbackAction {{ {{action:"continue"}} }} http::client::sse({{"method":"GET","url":"http://127.0.0.1:{port}/events"}}, go);"# + ); + let error = match run_sse_source(&source, config(port)).await { + Ok(_) => panic!("invalid SSE response must fail"), + Err(error) => error, + }; + assert!(error.to_string().contains(expected), "{error}"); + server.join().unwrap(); + } + + let listener = bind_test_listener(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = accept_test_connection(&listener).unwrap(); + let mut request = [0; 1024]; + let read = socket.read(&mut request).unwrap(); + assert!(read > 0, "SSE request should be received"); + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n").unwrap(); + socket.flush().unwrap(); + wait_for_test_timeout(std::time::Duration::from_millis(80)); + }); + let mut idle_config = config(port); + idle_config.stream_idle_timeout = std::time::Duration::from_millis(20); + let source = format!( + r#"use http; fn go(item: SseEvent) -> SseCallbackAction {{ {{action:"continue"}} }} http::client::sse({{"method":"GET","url":"http://127.0.0.1:{port}/events"}}, go);"# + ); + let error = match run_sse_source(&source, idle_config).await { + Ok(_) => panic!("idle SSE peer must time out"), + Err(error) => error, + }; + assert!(error.to_string().contains("idle timeout"), "{error}"); + server.join().unwrap(); + + let listener = bind_test_listener(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = accept_test_connection(&listener).unwrap(); + let mut request = [0; 1024]; + let read = socket.read(&mut request).unwrap(); + assert!(read > 0, "SSE request should be received"); + wait_for_test_timeout(std::time::Duration::from_millis(80)); + }); + let mut opening_config = config(port); + opening_config.stream_idle_timeout = std::time::Duration::from_millis(20); + let source = format!( + r#"use http; fn go(item: SseEvent) -> SseCallbackAction {{ {{action:"continue"}} }} http::client::sse({{"method":"GET","url":"http://127.0.0.1:{port}/events"}}, go);"# + ); + let error = match run_sse_source(&source, opening_config).await { + Ok(_) => panic!("SSE response opening must obey idle timeout"), + Err(error) => error, + }; + assert!( + error.to_string().contains("idle timeout while opening"), + "{error}" + ); + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_script_timeout_shortens_the_host_stream_duration() { + let listener = bind_test_listener(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = accept_test_connection(&listener).unwrap(); + let mut request = [0; 1024]; + assert!(socket.read(&mut request).unwrap() > 0); + wait_for_test_timeout(std::time::Duration::from_millis(80)); + }); + let mut deadline_config = config(port); + deadline_config.max_stream_duration = std::time::Duration::from_millis(200); + deadline_config.stream_idle_timeout = std::time::Duration::from_millis(200); + let source = format!( + r#"use http; fn go(item: SseEvent) -> SseCallbackAction {{ {{action:"continue"}} }} http::client::sse({{"method":"GET","url":"http://127.0.0.1:{port}/events","timeout_ms":20}}, go);"# + ); + let error = match run_sse_source(&source, deadline_config).await { + Ok(_) => panic!("script deadline should shorten the host maximum"), + Err(error) => error, + }; + assert!(error.to_string().contains("total deadline"), "{error}"); + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_host_stream_duration_caps_script_timeout_while_opening() { + let listener = bind_test_listener(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = accept_test_connection(&listener).unwrap(); + let mut request = [0; 1024]; + let _ = socket.read(&mut request); + wait_for_test_timeout(std::time::Duration::from_millis(600)); + }); + let mut deadline_config = config(port); + deadline_config.max_stream_duration = std::time::Duration::from_millis(250); + deadline_config.stream_idle_timeout = std::time::Duration::from_millis(800); + let source = format!( + r#"use http; fn go(item: SseEvent) -> SseCallbackAction {{ {{action:"continue"}} }} http::client::sse({{"method":"GET","url":"http://127.0.0.1:{port}/events","timeout_ms":1000}}, go);"# + ); + let error = match run_sse_source(&source, deadline_config).await { + Ok(_) => panic!("host duration should cap the script timeout during opening"), + Err(error) => error, + }; + assert!(error.to_string().contains("total deadline"), "{error}"); + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_total_deadline_expires_despite_periodic_progress_below_idle_timeout() { + let listener = bind_test_listener(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = accept_test_connection(&listener).unwrap(); + let mut request = [0; 1024]; + assert!(socket.read(&mut request).unwrap() > 0); + socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n").unwrap(); + socket.flush().unwrap(); + for _ in 0..40 { + wait_for_test_timeout(std::time::Duration::from_millis(25)); + if socket.write_all(b"c\r\ndata: tick\n\n\r\n").is_err() { + break; + } + if socket.flush().is_err() { + break; + } + } + }); + let mut deadline_config = config(port); + deadline_config.max_stream_duration = std::time::Duration::from_millis(600); + deadline_config.stream_idle_timeout = std::time::Duration::from_millis(250); + let callbacks = Arc::new(AtomicUsize::new(0)); + let source = format!( + r#"use http; + fn count_call() -> bool; + fn go(item: SseEvent) -> SseCallbackAction {{ + {{action: if count_call() => {{"continue"}} else => {{"continue"}}}} + }} + http::client::sse({{"method":"GET","url":"http://127.0.0.1:{port}/events"}}, go);"# + ); + let compiled = compile_source(&source).unwrap(); + let mut vm = Vm::new(compiled.program); + vm.configure_http(deadline_config).unwrap(); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + let mut registry = HostFunctionRegistry::new(); + registry.register_stack("count_call", 0, { + let callbacks = Arc::clone(&callbacks); + move || { + Box::new(CountCalls { + calls: Arc::clone(&callbacks), + }) + } + }); + registry.bind_vm_cached(&mut vm).unwrap(); + let error = drive(&mut vm) + .await + .expect_err("periodic progress must not extend the total deadline"); + assert!(error.to_string().contains("total deadline"), "{error}"); + server.join().unwrap(); + assert!( + callbacks.load(Ordering::SeqCst) >= 4, + "multiple progress events must reach callbacks inside the idle bound" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_total_deadline_releases_the_connection_permit_for_reuse() { + let listener = bind_test_listener(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut first, _) = accept_test_connection(&listener).unwrap(); + let mut request = [0; 1024]; + assert!(first.read(&mut request).unwrap() > 0); + let first = thread::spawn(move || { + wait_for_test_timeout(std::time::Duration::from_millis(80)); + drop(first); + }); + + let (mut second, _) = accept_test_connection(&listener).unwrap(); + let mut request = [0; 1024]; + assert!(second.read(&mut request).unwrap() > 0); + second + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\n\r\n", + ) + .unwrap(); + first.join().unwrap(); + }); + let source = format!( + r#"use http; http::client::sse({{"method":"GET","url":"http://127.0.0.1:{port}/events"}}, |item| {{action:"continue"}});"# + ); + let compiled = compile_source(&source).unwrap(); + let mut vm = Vm::new(compiled.program); + vm.set_http_max_in_flight(1); + let mut deadline_config = config(port); + deadline_config.max_stream_duration = std::time::Duration::from_millis(20); + deadline_config.stream_idle_timeout = std::time::Duration::from_millis(200); + vm.configure_http(deadline_config).unwrap(); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + + let error = drive(&mut vm) + .await + .expect_err("the first stream should reach its total deadline"); + assert!(error.to_string().contains("total deadline"), "{error}"); + vm.reset_for_reuse().expect("SSE reset should complete"); + drive(&mut vm) + .await + .expect("the second stream should acquire the released permit"); + assert_eq!(field(&vm.stack()[0], "outcome"), &Value::string("eof")); + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_callback_stop_after_deadline_fails_and_releases_permit_without_another_poll() { + let listener = bind_test_listener(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut first, _) = accept_test_connection(&listener).unwrap(); + let mut request = [0; 1024]; + assert!(first.read(&mut request).unwrap() > 0); + first + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n", + ) + .unwrap(); + first.flush().unwrap(); + let first = thread::spawn(move || { + wait_for_test_timeout(std::time::Duration::from_millis(500)); + drop(first); + }); + + let (mut second, _) = accept_test_connection(&listener).unwrap(); + let mut request = [0; 1024]; + assert!(second.read(&mut request).unwrap() > 0); + second + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\n\r\n", + ) + .unwrap(); + first.join().unwrap(); + }); + let source = format!( + r#" + use http; + fn async_wait() -> bool; + http::client::sse( + {{"method":"GET","url":"http://127.0.0.1:{port}/events"}}, + |item| {{ + action: if async_wait() => {{ "stop" }} else => {{ "stop" }} + }} + ); + "# + ); + let compiled = compile_source(&source).unwrap(); + let mut vm = Vm::new(compiled.program); + vm.set_http_max_in_flight(1); + let mut deadline_config = config(port); + deadline_config.max_stream_duration = std::time::Duration::from_millis(100); + deadline_config.stream_idle_timeout = std::time::Duration::from_secs(1); + vm.configure_http(deadline_config).unwrap(); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + let wait_calls = Arc::new(AtomicUsize::new(0)); + let mut registry = HostFunctionRegistry::new(); + registry.register_stack("async_wait", 0, { + let wait_calls = Arc::clone(&wait_calls); + move || { + Box::new(AsyncWaitOnce { + calls: Arc::clone(&wait_calls), + }) + } + }); + registry.bind_vm_cached(&mut vm).unwrap(); + + let error = drive(&mut vm) + .await + .expect_err("a callback action after the total deadline must fail"); + assert!( + matches!(error, VmError::HostError(ref message) if message == "SSE total deadline exceeded"), + "{error}" + ); + assert_eq!(wait_calls.load(Ordering::SeqCst), 1); + assert!(vm.stack().iter().all(|value| { + let Value::Map(map) = value else { + return true; + }; + map.get(&Value::string("outcome")) != Some(&Value::string("stopped")) + })); + + vm.reset_for_reuse().expect("SSE reset should complete"); + drive(&mut vm) + .await + .expect("the next stream should acquire the released permit"); + assert_eq!(wait_calls.load(Ordering::SeqCst), 2); + assert_eq!(field(&vm.stack()[0], "outcome"), &Value::string("stopped")); + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_callback_continue_after_deadline_fails_before_another_network_poll() { + let listener = bind_test_listener(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = accept_test_connection(&listener).unwrap(); + let mut request = [0; 1024]; + assert!(socket.read(&mut request).unwrap() > 0); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n", + ) + .unwrap(); + socket.flush().unwrap(); + wait_for_test_timeout(std::time::Duration::from_millis(500)); + }); + let source = format!( + r#" + use http; + fn async_wait() -> bool; + http::client::sse( + {{"method":"GET","url":"http://127.0.0.1:{port}/events"}}, + |item| {{ + action: if async_wait() => {{ "continue" }} else => {{ "continue" }} + }} + ); + "# + ); + let compiled = compile_source(&source).unwrap(); + let mut vm = Vm::new(compiled.program); + let mut deadline_config = config(port); + deadline_config.max_stream_duration = std::time::Duration::from_millis(100); + deadline_config.stream_idle_timeout = std::time::Duration::from_secs(1); + vm.configure_http(deadline_config).unwrap(); + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); + let wait_calls = Arc::new(AtomicUsize::new(0)); + let mut registry = HostFunctionRegistry::new(); + registry.register_stack("async_wait", 0, { + let wait_calls = Arc::clone(&wait_calls); + move || { + Box::new(AsyncWaitOnce { + calls: Arc::clone(&wait_calls), + }) + } + }); + registry.bind_vm_cached(&mut vm).unwrap(); + + let error = drive(&mut vm) + .await + .expect_err("a continue action after the total deadline must fail"); + assert!( + matches!(error, VmError::HostError(ref message) if message == "SSE total deadline exceeded"), + "{error}" + ); + assert_eq!(wait_calls.load(Ordering::SeqCst), 1); + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_chunked_trailers_cannot_bypass_total_body_limits() { + let trailer = format!("0\r\nX-Trailer: {}\r\n\r\n", "a".repeat(64 * 1024)); + let trailer = Box::leak(trailer.into_bytes().into_boxed_slice()); + let (port, server) = server(vec![ + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n", + b"9\r\nda", + b"ta: x\n\n\r\n", + trailer, + ]); + let mut stream_config = config(port); + stream_config.max_stream_total_bytes = 9; + let source = format!( + r#"use http; fn on_event(item: SseEvent) -> SseCallbackAction {{ {{action: "continue"}} }} http::client::sse({{"method":"GET","url":"http://127.0.0.1:{port}/events"}}, on_event);"# + ); + let error = match run_sse_source(&source, stream_config).await { + Ok(_) => panic!("oversized SSE trailers must be rejected"), + Err(error) => error, + }; + assert!( + contains_ascii_case_insensitive(&error.to_string(), "response") + || contains_ascii_case_insensitive(&error.to_string(), "connection"), + "unexpected trailer-limit error: {error}" + ); + server.join().unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn sse_revalidates_redirects_and_strips_cross_origin_credentials() { + for status in [301, 302, 303, 307, 308] { + let (target_port, target_requests, target) = recording_server(vec![vec![ + b"HTTP/1.1 200 OK\r\nContent-Type: Text/Event-Stream; Charset=UTF-8\r\nX-Obs: \x80\r\nX-Repeat: first\r\nX-Repeat: second\r\nContent-Length: 0\r\n\r\n", + ]]); + let redirect = format!( + "HTTP/1.1 {status} Redirect\r\nLocation: http://127.0.0.1:{target_port}/final\r\nContent-Length: 0\r\n\r\n" + ); + let redirect = Box::leak(redirect.into_bytes().into_boxed_slice()); + let (source_port, source_requests, source_server) = recording_server(vec![vec![redirect]]); + let source_code = format!( + r#" + use http; + fn record(item: SseEvent) -> SseCallbackAction {{ + if item.kind == "open" && item.status != 200 {{ let _ = 1 / 0; }} + if item.kind == "end" && item.status != null {{ let _ = 1 / 0; }} + {{action: "continue"}} + }} + http::client::sse( + {{method: "POST", url: "http://127.0.0.1:{source_port}/start", body: {{ kind: "text", text: "payload" }}, headers: [ + {{ name: "Authorization", value: "Bearer secret" }}, + {{ name: "Proxy-Authorization", value: "Basic proxy-secret" }}, + {{ name: "Cookie", value: "a=b" }}, + {{ name: "X-Api-Key", value: "api-secret" }}, + {{ name: "X-Arbitrary", value: "custom-secret" }}, + {{ name: "Content-Type", value: "application/body" }}, + {{ name: "Accept", value: "text/event-stream" }}, + {{ name: "Accept-Language", value: "en-US" }}, + {{ name: "Accept-Encoding", value: "identity" }} + ]}}, + record + ); + "# + ); + let mut allowed = config(source_port); + allowed.allowed_ports.push(target_port); + let vm = run_sse_source(&source_code, allowed).await.unwrap(); + let final_url = format!("http://127.0.0.1:{target_port}/final"); + assert_eq!( + &vm.stack()[0], + &map([ + ("outcome", Value::string("eof")), + ("status", Value::Int(200)), + ( + "headers", + Value::Array(Arc::new(vec![ + map([ + ("name", Value::string("content-length")), + ( + "value", + map([ + ("kind", Value::string("text")), + ("text", Value::string("0")), + ("bytes", Value::Null), + ]), + ), + ]), + map([ + ("name", Value::string("content-type")), + ( + "value", + map([ + ("kind", Value::string("text")), + ("text", Value::string("Text/Event-Stream; Charset=UTF-8")), + ("bytes", Value::Null), + ]), + ), + ]), + map([ + ("name", Value::string("x-obs")), + ( + "value", + map([ + ("kind", Value::string("bytes")), + ("text", Value::Null), + ("bytes", Value::bytes(vec![0x80])), + ]), + ), + ]), + map([ + ("name", Value::string("x-repeat")), + ( + "value", + map([ + ("kind", Value::string("text")), + ("text", Value::string("first")), + ("bytes", Value::Null), + ]), + ), + ]), + map([ + ("name", Value::string("x-repeat")), + ( + "value", + map([ + ("kind", Value::string("text")), + ("text", Value::string("second")), + ("bytes", Value::Null), + ]), + ), + ]), + ])), + ), + ("url", Value::string(final_url)), + ("items", Value::Int(2)), + ("bytes_received", Value::Int(0)), + ("bytes_sent", Value::Int(0)), + ]) + ); + let first = source_requests.recv().unwrap(); + assert_eq!(request_line(&first), "POST /start HTTP/1.1"); + assert!(first.ends_with("payload")); + assert_eq!(header_value(&first, "authorization"), Some("Bearer secret")); + assert_eq!( + header_value(&first, "proxy-authorization"), + Some("Basic proxy-secret") + ); + assert_eq!(header_value(&first, "cookie"), Some("a=b")); + assert_eq!(header_value(&first, "x-api-key"), Some("api-secret")); + assert_eq!(header_value(&first, "x-arbitrary"), Some("custom-secret")); + let second = target_requests.recv().unwrap(); + let expected_method = if status == 307 || status == 308 { + "POST" + } else { + "GET" + }; + assert!( + request_line(&second).starts_with(&format!("{expected_method} /final HTTP/1.1")), + "status {status}: " + ); + let expected_host = format!("127.0.0.1:{target_port}"); + assert_eq!( + header_value(&second, "host"), + Some(expected_host.as_str()), + "status {status}: authority was not rebuilt" + ); + assert_eq!(header_value(&second, "transfer-encoding"), None); + if expected_method == "POST" { + assert!(second.ends_with("payload"), "status {status}: "); + assert!( + header_value(&second, "content-length").is_none_or(|value| value == "7"), + "status {status}: stale content length in " + ); + } else { + assert!(!second.ends_with("payload"), "status {status}: "); + assert_eq!(header_value(&second, "content-type"), None); + assert_eq!(header_value(&second, "transfer-encoding"), None); + assert!( + header_value(&second, "content-length").is_none_or(|value| value == "0"), + "status {status}: stale content length in " + ); + } + for forbidden in [ + "authorization", + "proxy-authorization", + "cookie", + "x-api-key", + "x-arbitrary", + ] { + assert!( + !has_header(&second, forbidden), + "status {status}: " + ); + } + for safe in ["accept", "accept-language", "accept-encoding"] { + assert!(has_header(&second, safe), "status {status}: "); + } + source_server.join().unwrap(); + target.join().unwrap(); + } +} diff --git a/tests/vm/io_http_coexistence_tests.rs b/tests/vm/io_http_coexistence_tests.rs new file mode 100644 index 00000000..071ca410 --- /dev/null +++ b/tests/vm/io_http_coexistence_tests.rs @@ -0,0 +1,465 @@ +//! IO and HTTP coexistence tests for the async host-adapter build. +#![cfg(all(feature = "http-client", not(target_family = "wasm")))] + +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::thread; +use std::time::Duration; + +use vm::{ + CallReturn, HostAsyncBridge, HostFunctionRegistry, HostFuture, HostFutureOutput, HostOpId, + HttpConfig, HttpHostExt, IoHostExt, IoPolicy, ResourceTypeKey, Value, Vm, VmError, VmResult, + VmStatus, compile_source, register_http_builtin_module, standard_host_catalog, +}; + +#[derive(Default)] +struct TokioHostDriver { + submitted: HashMap, +} + +impl HostAsyncBridge for TokioHostDriver { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + self.submitted.insert(op_id, future); + Ok(()) + } + + fn poll_op(&mut self, op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(VmError::HostError(format!( + "unknown external host operation {op_id}" + )))) + } + + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + let poll = self.submitted.get_mut(&op_id).map_or_else( + || { + Poll::Ready(Err(VmError::HostError(format!( + "unknown submitted host operation {op_id}" + )))) + }, + |future| future.as_mut().poll(cx), + ); + if poll.is_ready() { + self.submitted.remove(&op_id); + } + poll + } + + fn cancel_op(&mut self, op_id: HostOpId) { + self.submitted.remove(&op_id); + } +} + +fn install_host_driver(vm: &mut Vm) { + vm.set_async_bridge(Box::::default()) + .expect("test async bridge should install"); +} + +fn bind_default_host_registry(vm: &mut Vm) { + HostFunctionRegistry::new() + .bind_vm_cached(vm) + .expect("default IO and HTTP host functions should bind"); +} + +fn local_http_config(port: u16) -> HttpConfig { + HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![port], + allow_private_ips: true, + connect_timeout: Duration::from_secs(5), + request_timeout: Duration::from_secs(5), + ..HttpConfig::default() + } +} + +fn spawn_http_server(requests: usize) -> (u16, thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener should bind"); + let port = listener + .local_addr() + .expect("test listener should have an address") + .port(); + let server = thread::spawn(move || { + for _ in 0..requests { + let (mut stream, _) = listener.accept().expect("HTTP request should arrive"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream + .read(&mut buffer) + .expect("HTTP request should be readable"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + assert!(request.starts_with(b"GET / HTTP/1.1")); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nX-Test: yes\r\n\r\nhello") + .expect("HTTP response should be writable"); + } + }); + (port, server) +} + +async fn drive_vm_to_halt(vm: &mut Vm) -> VmResult<()> { + let mut status = vm.run()?; + loop { + match status { + VmStatus::Halted => return Ok(()), + VmStatus::Yielded => status = vm.resume()?, + VmStatus::Waiting(_) => { + vm.await_waiting_host_op().await?; + status = vm.resume()?; + } + } + } +} + +async fn finish_reset(vm: &mut Vm) -> VmResult<()> { + vm.reset_for_reuse()?; + if vm.scope_reset_pending() { + std::future::poll_fn(|cx| vm.poll_reset_for_reuse(cx)).await?; + } + assert!(vm.is_reusable(), "VM should be reusable after reset"); + Ok(()) +} + +fn response_field<'a>(value: &'a Value, key: &str) -> &'a Value { + let Value::Map(map) = value else { + panic!("expected response map, got {value:?}"); + }; + map.get(&Value::string(key)) + .unwrap_or_else(|| panic!("response missing field {key}")) +} + +fn http_request_source(port: u16) -> String { + format!( + "use http; http::client::request({{\"method\": \"GET\", \"url\": \"http://127.0.0.1:{port}/\"}});" + ) +} + +#[tokio::test(flavor = "current_thread")] +async fn io_and_http_both_register_via_shared_vm() { + let compiled = compile_source( + r#" + use io; + use http; + io::exists("/"); + "#, + ) + .expect("IO and HTTP source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_io(IoPolicy { + allowed_roots: vec!["/".to_string()], + ..IoPolicy::default() + }); + vm.configure_http(HttpConfig::default()) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + bind_default_host_registry(&mut vm); + + drive_vm_to_halt(&mut vm) + .await + .expect("IO call should complete beside registered HTTP"); + assert_eq!(vm.stack().last(), Some(&Value::Bool(true))); +} + +#[tokio::test(flavor = "current_thread")] +async fn io_and_http_execute_together() { + let (port, server) = spawn_http_server(1); + let source = format!( + r#" + use io; + use http; + let exists = io::exists("/"); + let response = http::client::request({{"method": "GET", "url": "http://127.0.0.1:{port}/"}}); + response.status; + "# + ); + let compiled = compile_source(&source).expect("combined source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_io(IoPolicy { + allowed_roots: vec!["/".to_string()], + ..IoPolicy::default() + }); + vm.configure_http(local_http_config(port)) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + bind_default_host_registry(&mut vm); + + drive_vm_to_halt(&mut vm) + .await + .expect("IO and HTTP calls should complete"); + server.join().expect("HTTP server should finish"); + assert_eq!(vm.stack().last(), Some(&Value::Int(200))); +} + +#[tokio::test(flavor = "current_thread")] +async fn io_policy_persists_independently_of_http_config() { + let compiled = compile_source( + r#" + use io; + use http; + io::exists("/forbidden"); + "#, + ) + .expect("source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_io(IoPolicy::default()); + vm.configure_http(HttpConfig::default()) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + bind_default_host_registry(&mut vm); + + let first_error = drive_vm_to_halt(&mut vm) + .await + .expect_err("default IO policy should reject the path"); + assert!(first_error.to_string().contains("allowed roots")); + finish_reset(&mut vm) + .await + .expect("reset should retire the failed IO invocation"); + let second_error = drive_vm_to_halt(&mut vm) + .await + .expect_err("IO policy should remain restrictive after reset"); + assert!(second_error.to_string().contains("allowed roots")); +} + +#[tokio::test(flavor = "current_thread")] +async fn http_config_persists_independently_of_io_config() { + let (port, server) = spawn_http_server(2); + let compiled = compile_source(&http_request_source(port)).expect("HTTP source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_io(IoPolicy { + allowed_roots: vec!["/".to_string()], + ..IoPolicy::default() + }); + vm.configure_http(local_http_config(port)) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + bind_default_host_registry(&mut vm); + + drive_vm_to_halt(&mut vm) + .await + .expect("first HTTP request should complete"); + finish_reset(&mut vm) + .await + .expect("reset should retire the first HTTP invocation"); + drive_vm_to_halt(&mut vm) + .await + .expect("HTTP configuration should persist for the second invocation"); + server.join().expect("HTTP server should finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn io_and_http_coexist_through_vm_reset_cycle() { + let compiled = compile_source( + r#" + use io; + use http; + io::exists("/"); + "#, + ) + .expect("source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_io(IoPolicy { + allowed_roots: vec!["/".to_string()], + ..IoPolicy::default() + }); + vm.configure_http(HttpConfig::default()) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + bind_default_host_registry(&mut vm); + + drive_vm_to_halt(&mut vm) + .await + .expect("first invocation should complete"); + finish_reset(&mut vm) + .await + .expect("reset should reach generic scope quiescence"); + drive_vm_to_halt(&mut vm) + .await + .expect("second invocation should use the replacement scope"); +} + +#[tokio::test(flavor = "current_thread")] +async fn worker_cleanup_reaches_quiescence_after_io_and_http() { + let (port, server) = spawn_http_server(1); + let source = format!( + r#" + use io; + use http; + let response = http::client::request({{"method": "GET", "url": "http://127.0.0.1:{port}/"}}); + let exists = io::exists("/"); + response.status; + "# + ); + let compiled = compile_source(&source).expect("combined source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_io(IoPolicy { + allowed_roots: vec!["/".to_string()], + ..IoPolicy::default() + }); + vm.configure_http(local_http_config(port)) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + bind_default_host_registry(&mut vm); + + drive_vm_to_halt(&mut vm) + .await + .expect("combined invocation should complete"); + server.join().expect("HTTP server should finish"); + finish_reset(&mut vm) + .await + .expect("reset should wait for all worker and transport state"); +} + +#[test] +fn io_and_http_resource_type_keys_are_disjoint() { + let catalog = standard_host_catalog(); + let io_keys = ["io.file", "io.socket", "io.process", "io.worker", "io.pipe"]; + let http_keys = ["http.request", "http.response", "http.sse"]; + for key in catalog + .resources() + .iter() + .map(|resource| resource.key.as_str()) + { + if io_keys.contains(&key) { + assert!(!http_keys.contains(&key)); + } + if http_keys.contains(&key) { + assert!(!io_keys.contains(&key)); + } + } + for key in io_keys { + let _ = ResourceTypeKey::new(key).expect("IO resource key should be valid"); + } + for key in http_keys { + let _ = ResourceTypeKey::new(key).expect("HTTP resource key should be valid"); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn io_and_http_module_states_are_independent() { + let compiled = compile_source( + r#" + use io; + use http; + true; + "#, + ) + .expect("source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_io(IoPolicy { + allowed_roots: vec!["/safe".to_string()], + max_read_bytes: 4096, + max_write_bytes: 4096, + ..IoPolicy::default() + }); + vm.configure_http(HttpConfig::default()) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + bind_default_host_registry(&mut vm); + drive_vm_to_halt(&mut vm) + .await + .expect("module-only invocation should complete"); + finish_reset(&mut vm) + .await + .expect("independent module state should permit reset"); +} + +#[test] +fn explicit_standard_catalog_emits_exact_http_import_schema() { + let catalog = standard_host_catalog(); + let compiled = vm::compile_source_with_flavor_and_options( + r#" + use http; + http::client::request({"method": "GET", "url": "http://127.0.0.1:1/"}); + "#, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)), + ) + .expect("catalog-backed HTTP source should compile"); + let index = compiled + .program + .imports + .iter() + .position(|import| import.name == "http::client::request") + .expect("HTTP request should be a host import"); + let schema = compiled + .program + .host_import_schemas() + .get(index) + .and_then(Option::as_ref) + .expect("HTTP import should carry its exact schema"); + assert_eq!(schema.fingerprint, catalog.fingerprint()); + + let mut registry = HostFunctionRegistry::empty(); + register_http_builtin_module(&mut registry).expect("HTTP exact registration should succeed"); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("catalog-backed HTTP import should exact-bind"); +} + +#[tokio::test(flavor = "current_thread")] +async fn combined_standard_http_exact_bind_executes_with_io_surface_present() { + let (port, server) = spawn_http_server(1); + let catalog = standard_host_catalog(); + let source = format!( + r#" + use io; + use http; + let response = http::client::request({{"method": "GET", "url": "http://127.0.0.1:{port}/"}}); + response; + "# + ); + let compiled = vm::compile_source_with_flavor_and_options( + &source, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)), + ) + .expect("combined catalog source should compile"); + assert!( + compiled + .program + .host_import_schemas() + .iter() + .all(Option::is_some) + ); + let mut registry = HostFunctionRegistry::empty(); + register_http_builtin_module(&mut registry).expect("HTTP exact registration should succeed"); + let mut vm = Vm::new(compiled.program); + vm.configure_io(IoPolicy { + allowed_roots: vec!["/".to_string()], + ..IoPolicy::default() + }); + vm.configure_http(local_http_config(port)) + .expect("HTTP configuration should be valid"); + install_host_driver(&mut vm); + registry + .bind_vm_cached(&mut vm) + .expect("combined exact HTTP import should bind"); + drive_vm_to_halt(&mut vm) + .await + .expect("combined exact HTTP request should complete"); + server.join().expect("HTTP server should finish"); + assert_eq!(response_field(&vm.stack()[0], "status"), &Value::Int(200)); +} + +#[test] +fn standard_catalog_contains_io_and_http_surfaces() { + let catalog = standard_host_catalog(); + for name in ["io::open", "http::client::request"] { + assert!( + !catalog.functions_named(name).is_empty(), + "standard catalog should contain {name}" + ); + } +} diff --git a/tests/vm/vm_async_runtime_tests.rs b/tests/vm/vm_async_runtime_tests.rs index c7e4a1ec..b3e79130 100644 --- a/tests/vm/vm_async_runtime_tests.rs +++ b/tests/vm/vm_async_runtime_tests.rs @@ -115,6 +115,32 @@ impl HostAsyncBridge for TestAsyncBridge { } } +struct RejectingCancelBridge; + +impl HostAsyncBridge for RejectingCancelBridge { + fn submit_op(&mut self, _op_id: HostOpId, _future: vm::HostFuture) -> Result<(), VmError> { + Ok(()) + } + + fn poll_op( + &mut self, + _op_id: HostOpId, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } + + fn request_cancel_op( + &mut self, + _op_id: HostOpId, + _reason: vm::operation::OperationCancelReason, + ) -> Result<(), VmError> { + Err(VmError::HostError( + "cancellation request rejected".to_string(), + )) + } +} + struct AsyncAddOneFunction { ops: SharedAsyncOps, calls: Arc, @@ -249,6 +275,30 @@ async fn reset_cancels_pending_host_bridge_operation() { assert_eq!(vm.waiting_host_op_id(), None); } +#[test] +fn failed_bridge_reset_does_not_mark_vm_reusable() { + let mut vm = Vm::new(Program::new(Vec::new(), vec![vm::OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(RejectingCancelBridge)) + .expect("bridge should install"); + vm.submit_host_future(Box::pin(std::future::pending::< + Result, + >())) + .expect("bridge should accept pending future"); + + let error = vm + .reset_for_reuse() + .expect_err("rejected cancellation must fail reset"); + assert!( + matches!(error, VmError::HostError(ref message) if message.contains("rejected")), + "unexpected reset error: {error:?}" + ); + assert!(!vm.is_reusable(), "failed reset must poison VM reuse"); + assert!( + matches!(vm.run(), Err(VmError::HostError(message)) if message.contains("rejected")), + "failed reset must block execution" + ); +} + #[tokio::test(flavor = "current_thread")] async fn vm_waiting_on_async_host_op_does_not_block_tokio_tasks() { let ops = Arc::new(Mutex::new(TestAsyncOps::default())); diff --git a/tests/wire/wire_tests.rs b/tests/wire/wire_tests.rs index 8c075b79..487a9f39 100644 --- a/tests/wire/wire_tests.rs +++ b/tests/wire/wire_tests.rs @@ -6,8 +6,8 @@ use vm::{ HostFunctionSchema, HostImport, HostImportSchema, HostParamPassing, HostParamSchema, HostTypeSchema, LineInfo, LocalInfo, Program, ResourceTypeKey, ResourceTypeSchema, ScriptFunction, TypeMap, ValidationError, Value, ValueType, WireError, builtin_call_index, - decode_program, disassemble_vmbc, disassemble_vmbc_with_options, encode_program, - infer_local_count, validate_program, + compile_source, decode_program, disassemble_vmbc, disassemble_vmbc_with_options, + encode_program, infer_local_count, validate_program, }; #[test] @@ -58,7 +58,7 @@ fn wire_roundtrip_preserves_constants_and_code() { }); let encoded = encode_program(&program).expect("encode should succeed"); - assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 12); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 13); let decoded = decode_program(&encoded).expect("decode should succeed"); assert_eq!(decoded.constants, program.constants); @@ -81,11 +81,12 @@ fn wire_v11_legacy_imports_decode_without_schema_metadata() { vec![import.clone()], None, ); - let encoded = encode_program(&program).expect("v12 encoding should succeed"); + let encoded = encode_program(&program).expect("v13 encoding should succeed"); let marker_offset = 8 + 4 + 4 + program.code.len() + 4 + 4 + import.name.len() + 2; assert_eq!(encoded[marker_offset], 0); let mut legacy = encoded; legacy.drain(marker_offset..marker_offset + 1); + strip_empty_named_struct_section(&mut legacy); legacy[4..6].copy_from_slice(&11u16.to_le_bytes()); let decoded = decode_program(&legacy).expect("v11 payload should remain readable"); @@ -96,7 +97,8 @@ fn wire_v11_legacy_imports_decode_without_schema_metadata() { #[test] fn wire_v11_zero_import_program_decodes_by_version() { let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); - let mut encoded = encode_program(&program).expect("v12 encoding should succeed"); + let mut encoded = encode_program(&program).expect("v13 encoding should succeed"); + strip_empty_named_struct_section(&mut encoded); encoded[4..6].copy_from_slice(&11u16.to_le_bytes()); let decoded = decode_program(&encoded).expect("schema-less v11 payload should decode"); @@ -105,6 +107,19 @@ fn wire_v11_zero_import_program_decodes_by_version() { assert!(decoded.host_import_schemas().is_empty()); } +fn strip_empty_named_struct_section(encoded: &mut Vec) { + assert!( + encoded.len() >= 4, + "encoded VMBC is too short to contain a named-struct section" + ); + assert_eq!( + &encoded[encoded.len() - 4..], + &[0, 0, 0, 0], + "expected an empty named-struct count trailer on current encode" + ); + encoded.truncate(encoded.len() - 4); +} + fn minimal_vmbc_prefix(constant_count: u32, code: &[u8], import_count: u32) -> Vec { let mut bytes = Vec::new(); bytes.extend_from_slice(b"VMBC"); @@ -1051,19 +1066,20 @@ fn validate_rejects_call_script_targeting_host_import_prototype() { } #[test] -fn call_script_wire_version_is_v12_and_v11_accepts_schema_less_program() { +fn call_script_wire_version_is_v13_and_v11_accepts_schema_less_program() { let program = Program::new(vec![], vec![vm::OpCode::Ret as u8]); let encoded = encode_program(&program).expect("encode should succeed"); - assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 12); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 13); - let mut old = encoded.clone(); + let mut old = encoded; + strip_empty_named_struct_section(&mut old); old[4..6].copy_from_slice(&11u16.to_le_bytes()); decode_program(&old).expect("schema-less v11 program should decode"); } #[test] fn call_script_no_script_program_code_bytes_unchanged_by_version_bump() { - // The V12 bump must not alter instruction bytes for programs without + // Version bumps must not alter instruction bytes for programs without // script calls: encode a plain arithmetic program and verify the // embedded code section is exactly the assembler output. let mut bc = BytecodeBuilder::new(); @@ -1073,8 +1089,47 @@ fn call_script_no_script_program_code_bytes_unchanged_by_version_bump() { bc.ret(); let program = Program::new(vec![Value::Int(1), Value::Int(2)], bc.finish()); let encoded = encode_program(&program).expect("encode should succeed"); - assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 12); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 13); let decoded = decode_program(&encoded).expect("decode should succeed"); assert_eq!(decoded.code, program.code); assert_eq!(decoded.constants, program.constants); } + +#[test] +fn v12_trailing_zero_count_is_not_a_named_struct_table() { + let program = Program::new(Vec::new(), vec![vm::OpCode::Ret as u8]); + let mut encoded = encode_program(&program).expect("v13 encoding should succeed"); + strip_empty_named_struct_section(&mut encoded); + encoded[4..6].copy_from_slice(&12u16.to_le_bytes()); + decode_program(&encoded).expect("clean v12 without a named-struct section should decode"); + + let mut garbage = encoded; + garbage.extend_from_slice(&0u32.to_le_bytes()); + assert!( + matches!(decode_program(&garbage), Err(WireError::TrailingBytes)), + "v12 must not treat a 4-byte zero trailer as an empty named-struct table" + ); +} + +#[test] +fn v13_roundtrip_preserves_guest_named_struct_payload() { + let compiled = compile_source( + r#" + struct Point { x: int, y: int } + fn ident(p: Point) -> Point { p } + ident({ x: 8, y: 9 }); + "#, + ) + .expect("guest Named source should compile"); + assert!( + compiled.program.named_struct_decls().contains_key("Point"), + "codegen should attach guest struct decls" + ); + let encoded = encode_program(&compiled.program).expect("struct-bearing program should encode"); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 13); + let decoded = decode_program(&encoded).expect("v13 named-struct section should decode"); + assert!( + decoded.named_struct_decls().contains_key("Point"), + "VMBC v13 should preserve guest struct decls" + ); +}