From 4b749862ff1632ef1266d75709da3676de37356c Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 30 Aug 2026 20:27:22 +0800 Subject: [PATCH] feat(http): add HTTP and SSE streams extension with delivery semantics --- Cargo.lock | 532 ++++- Cargo.toml | 48 +- build.rs | 43 +- docs/http-client.md | 61 + src/builtins/mod.rs | 2 +- src/builtins/runtime/http/config.rs | 100 + src/builtins/runtime/http/mod.rs | 556 +++++ src/builtins/runtime/http/policy.rs | 299 +++ src/builtins/runtime/http/request.rs | 1949 +++++++++++++++++ src/builtins/runtime/http/sse.rs | 1784 +++++++++++++++ src/builtins/runtime/mod.rs | 14 +- src/builtins/runtime/standard_composition.rs | 7 +- src/cli.rs | 2 +- src/lib.rs | 11 +- src/vm/async_host/mod.rs | 9 + src/vm/async_host/stream.rs | 564 +++++ src/vm/execution_scope.rs | 35 +- src/vm/host.rs | 138 +- src/vm/host_runtime.rs | 211 +- src/vm/instance.rs | 5 + src/vm/mod.rs | 84 +- src/vm/operation/driver.rs | 20 + src/vm/operation/registry.rs | 118 +- tests/builtins/io_async_tests.rs | 9 +- tests/builtins/io_scope_lifecycle_tests.rs | 9 +- .../builtins/sqlite_scope_lifecycle_tests.rs | 41 +- tests/builtins_tests.rs | 3 + .../external-host-extension/src/lib.rs | 8 + tests/http_feature_gating_tests.rs | 52 + tests/support/async_test_bridge.rs | 7 +- tests/support/vm_reset.rs | 45 + tests/vm/http_host_tests.rs | 1282 +++++++++++ tests/vm/http_sse_tests.rs | 1491 +++++++++++++ tests/vm/io_http_coexistence_tests.rs | 465 ++++ tests/vm/vm_async_runtime_tests.rs | 50 + 35 files changed, 9978 insertions(+), 76 deletions(-) create mode 100644 docs/http-client.md create mode 100644 src/builtins/runtime/http/config.rs create mode 100644 src/builtins/runtime/http/mod.rs create mode 100644 src/builtins/runtime/http/policy.rs create mode 100644 src/builtins/runtime/http/request.rs create mode 100644 src/builtins/runtime/http/sse.rs create mode 100644 src/vm/async_host/stream.rs create mode 100644 tests/http_feature_gating_tests.rs create mode 100644 tests/support/vm_reset.rs create mode 100644 tests/vm/http_host_tests.rs create mode 100644 tests/vm/http_sse_tests.rs create mode 100644 tests/vm/io_http_coexistence_tests.rs 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..69905225 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,26 @@ name = "host_context_arch_tests" path = "tests/host_context_arch_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 = "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/docs/http-client.md b/docs/http-client.md new file mode 100644 index 00000000..39196f18 --- /dev/null +++ b/docs/http-client.md @@ -0,0 +1,61 @@ +# 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 response map; 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. + +## 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/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..854b8f6c --- /dev/null +++ b/src/builtins/runtime/http/mod.rs @@ -0,0 +1,556 @@ +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, + 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", + )); + + // The dynamic request map is accepted as `unknown` because RustScript + // object literals are exact record types; the HTTP implementation + // validates the concrete contents at runtime. Schemas, keys, passing + // modes and fingerprints still come from this one catalog, so compiler + // and registry agree byte-for-byte. + builder.function(HostFunctionSchema::with_return( + "http::client::request", + vec![HostParamSchema::value("request", HostTypeSchema::Unknown)], + HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)), + )); + builder.function(HostFunctionSchema::with_return( + "http::client::sse", + vec![ + HostParamSchema::value("request", HostTypeSchema::Unknown), + HostParamSchema::with_passing( + "on_event", + HostTypeSchema::Callable { + params: vec![HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown))], + result: Box::new(HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown))), + }, + HostParamPassing::Value, + ), + ], + HostTypeSchema::Map(Box::new(HostTypeSchema::Unknown)), + )); + + Arc::new(builder.build().expect("http catalog must build")) +} + +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| { + 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 map accepts `method`, `url`, optional `headers`, and optional +/// `body`. The response map contains `status`, `headers`, `body`, and the +/// final `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..3dd77376 --- /dev/null +++ b/src/builtins/runtime/http/request.rs @@ -0,0 +1,1949 @@ +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 = match map.get(&Value::string("body")) { + None | Some(Value::Null) => None, + Some(Value::Bytes(bytes)) => { + if bytes.len() > config.max_request_body_bytes { + return Err(VmError::HostError( + "HTTP request body exceeds limit".to_string(), + )); + } + Some(bytes.as_ref().clone()) + } + Some(Value::String(text)) => { + if text.len() > config.max_request_body_bytes { + return Err(VmError::HostError( + "HTTP request body exceeds limit".to_string(), + )); + } + Some(text.as_bytes().to_vec()) + } + Some(_) => return Err(VmError::TypeMismatch("HTTP request body")), + }; + + let mut headers = Vec::new(); + let mut header_budget = RequestHeaderBudget::from_config(config); + if let Some(Value::Map(header_map)) = map.get(&Value::string("headers")) { + for (key, value) in header_map.iter() { + let Value::String(key) = key else { + return Err(VmError::TypeMismatch("HTTP header name")); + }; + let Value::String(value) = value else { + return Err(VmError::TypeMismatch("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)); + } + } else if map.get(&Value::string("headers")).is_some() { + return Err(VmError::TypeMismatch("HTTP headers")); + } + header_budget.finish()?; + + Ok(HttpRequest { + method, + url, + headers, + body, + }) +} + +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<(Value, Value)> { + headers + .iter() + .map(|(name, value)| { + let value = value + .to_str() + .map(Value::string) + .unwrap_or_else(|_| Value::bytes(value.as_bytes().to_vec())); + (Value::string(name.as_str()), 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<(Value, Value)>, + body: Vec, + url: &url::Url, +) -> VmMap { + VmMap::from_entries(vec![ + ( + Value::string("status"), + Value::Int(i64::from(status.as_u16())), + ), + ( + Value::string("headers"), + Value::Map(std::sync::Arc::new(VmMap::from_entries(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, 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_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::Map(std::sync::Arc::new(VmMap::from_entries( + headers + .into_iter() + .map(|(name, value)| (Value::string(name), Value::string(value))) + .collect(), + ))), + ); + request + } + + #[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..de8bc8ac --- /dev/null +++ b/src/builtins/runtime/http/sse.rs @@ -0,0 +1,1784 @@ +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 parse_stream_timeout(request: &VmMap) -> VmResult> { + let Some(value) = request.get(&Value::string("timeout_ms")) else { + return Ok(None); + }; + let Value::Int(milliseconds) = value else { + return Err(VmError::TypeMismatch("SSE timeout_ms")); + }; + 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))) +} + +/// 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(VmMap::from_entries(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( + map_value(vec![ + ("kind", Value::string("open")), + ("status", Value::Int(i64::from(status.as_u16()))), + ("headers", Value::Map(headers)), + ("url", Value::string(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( + map_value(vec![ + ("kind", Value::string("event")), + ("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)), + ]), + deadline, + ) + .await?; + } + } + } + parser.finish()?; + self.publish(map_value(vec![("kind", Value::string("end"))]), 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::Map(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::Map(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_stream_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(VmMap::default()), + 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/mod.rs b/src/builtins/runtime/mod.rs index fb12ea57..18f521be 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -8,7 +8,7 @@ use crate::host_api::{ HostApiBuilder, HostApiCatalog, HostFunctionSchema, HostParamPassing, HostParamSchema, 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 +21,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")] @@ -193,6 +195,16 @@ pub fn standard_host_catalog() -> Arc { )], HostTypeSchema::Null, )); + #[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 function in http_catalog.functions() { + builder.function(function.clone()); + } + } Arc::new(builder.build().expect("standard host catalog is valid")) })) } diff --git a/src/builtins/runtime/standard_composition.rs b/src/builtins/runtime/standard_composition.rs index 050ba9a1..3815ae1d 100644 --- a/src/builtins/runtime/standard_composition.rs +++ b/src/builtins/runtime/standard_composition.rs @@ -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, and SQLite under `sqlite`. 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/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/lib.rs b/src/lib.rs index b0283ab9..6cf01d32 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,15 @@ pub(crate) fn install_default_host_functions(registry: &mut vm::HostFunctionRegi builtins::runtime::register_default_host_functions(registry); } +#[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, @@ -52,7 +61,7 @@ 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, 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..54ae8efc --- /dev/null +++ b/src/vm/async_host/stream.rs @@ -0,0 +1,564 @@ +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 the argument and result schemas against `fn(map) -> 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, +} + +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 its argument and result schemas against `fn(map) -> map`. It + /// 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(_)]) + || !matches!(result.as_ref(), TypeSchema::Map(_))) + { + return Err(VmError::TypeMismatch("fn(map) -> map")); + } + 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..669ed3ca 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -8,7 +8,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::*; @@ -725,6 +725,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, @@ -1482,6 +1532,8 @@ pub(super) enum WaitingHostOpSource { HostBridge, Manual, ScopedOperation, + CallableStream, + CallableStreamTermination, } struct NoopWake; @@ -1973,11 +2025,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 +2050,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 +2065,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 +2120,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 +2136,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 +2160,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 +2212,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 +3017,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 +3162,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 +3237,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 +3267,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 { diff --git a/src/vm/host_runtime.rs b/src/vm/host_runtime.rs index c51759a5..08c74c29 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,12 @@ pub(crate) struct HostRuntime { bridge_operations: HashMap, /// Adapter-owned completions for operations driven by the execution scope. pub(crate) scoped_operation_completions: HashMap, + /// 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 +161,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, @@ -159,6 +173,8 @@ impl HostRuntime { 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 +477,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 +506,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 +536,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 +553,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 +581,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 +598,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..bd736855 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; @@ -976,11 +977,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 +1050,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 +1364,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 +1379,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 +1395,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(); @@ -3110,7 +3164,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 +3379,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/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..67988730 100644 --- a/tests/builtins/sqlite_scope_lifecycle_tests.rs +++ b/tests/builtins/sqlite_scope_lifecycle_tests.rs @@ -15,6 +15,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use vm::{SqliteHostExt, Vm, VmError, VmStatus, compile_source}; +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> { @@ -298,7 +300,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" @@ -336,3 +338,40 @@ fn sqlite_pending_operation_slots_are_reclaimed_after_completion() { .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/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/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/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/vm/http_host_tests.rs b/tests/vm/http_host_tests.rs new file mode 100644 index 00000000..6f940708 --- /dev/null +++ b/tests/vm/http_host_tests.rs @@ -0,0 +1,1282 @@ +#![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": "payload"}}); + "# + )) + .expect("HTTP request source 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": "payload", "headers": {{ + Authorization: "Bearer secret", + "Proxy-Authorization": "Basic proxy-secret", + Cookie: "a=b", + "X-Api-Key": "api-secret", + "X-Arbitrary": "custom-secret", + "Content-Type": "application/body", + Accept: "application/json", + "Accept-Language": "en-US", + "Accept-Encoding": "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 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 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_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(&program.imports) + .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(&program.imports) + .expect("registry should prepare HTTP plan"); + registry + .allow_builtin("http::client::request") + .expect("HTTP capability should be a known host callable"); + 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(&unchanged_program.imports) + .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(&branch_program.imports) + .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(&program.imports) + .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..1e856f94 --- /dev/null +++ b/tests/vm/http_sse_tests.rs @@ -0,0 +1,1491 @@ +#![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)))) + } +} + +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: map) -> map {{ + 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 != {{kind: "end"}} {{ 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)); +} + +#[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"), + ("\"1\"", "type mismatch"), + ] { + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ {{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}" + ); + } + + let source = r#" + use http; + fn callback(item: map) -> map { {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: map) -> map { {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: map) -> map { {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: map) -> map {{ {{action: "continue"}} }} + http::client::sse( + {{method: "POST", url: "http://127.0.0.1:{port}/events", body: "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: map) -> map {{ {{action: "continue"}} }} + http::client::sse({{method:"POST", url:"http://127.0.0.1:{port}/start", body:"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: map) -> map {{ {{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: map) -> map {{ {{action: "continue"}} }} + http::client::sse( + {{method:"GET", url:"http://127.0.0.1:{port}/start", headers:{{Authorization:"Bearer secret", Cookie:"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: map) -> map {{ {{action: "continue"}} }} + http::client::sse( + {{method:"GET", url:"http://127.0.0.1:{source_port}/start", headers:{{Authorization:"Bearer secret", Cookie:"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: map) -> map {{ {{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: map) -> map {{ {{action: "continue"}} }} + fn outer(item: map) -> map {{ + 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: map) -> map {{ {{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: map) -> map {{ {{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: map) -> map {{ {{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: map) -> map {{ {{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: map) -> map {{ {{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: map) -> map {{ + {{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: map) -> map {{ {{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\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: map) -> map {{ + if item["kind"] == "open" && item != {{ + kind: "open", + status: 200, + headers: {{"content-type": "Text/Event-Stream; Charset=UTF-8", "x-obs": b"\x80", "content-length": "0"}}, + url: "http://127.0.0.1:{target_port}/final" + }} {{ let _ = 1 / 0; }} + if item["kind"] == "end" && item != {{kind: "end"}} {{ let _ = 1 / 0; }} + {{action: "continue"}} + }} + http::client::sse( + {{method: "POST", url: "http://127.0.0.1:{source_port}/start", body: "payload", headers: {{ + Authorization: "Bearer secret", + "Proxy-Authorization": "Basic proxy-secret", + Cookie: "a=b", + "X-Api-Key": "api-secret", + "X-Arbitrary": "custom-secret", + "Content-Type": "application/body", + Accept: "text/event-stream", + "Accept-Language": "en-US", + "Accept-Encoding": "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", + map([ + ( + "content-type", + Value::string("Text/Event-Stream; Charset=UTF-8"), + ), + ("x-obs", Value::bytes(vec![0x80])), + ("content-length", Value::string("0")), + ]), + ), + ("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..a8cdcfec --- /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()));