diff --git a/Cargo.lock b/Cargo.lock index 64ad2d98..07cbe305 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -102,12 +111,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - [[package]] name = "chacha20" version = "0.10.1" @@ -128,6 +131,12 @@ dependencies = [ "error-code", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cpufeatures" version = "0.3.0" @@ -303,6 +312,38 @@ version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d953932541249c91e3fa70a75ff1e52adc62979a2a8132145d4b9b3e6d1a9b6a" +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -407,12 +448,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" -[[package]] -name = "futures-io" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" - [[package]] name = "futures-macro" version = "0.3.33" @@ -426,9 +461,9 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" @@ -443,11 +478,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", - "futures-io", "futures-macro", "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -459,10 +492,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", "wasi", - "wasm-bindgen", ] [[package]] @@ -472,11 +503,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "rand_core", - "wasm-bindgen", ] [[package]] @@ -578,6 +607,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -598,43 +636,18 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", "bytes", - "futures-channel", - "futures-util", "http", "http-body", "hyper", - "ipnet", - "libc", - "percent-encoding", "pin-project-lite", - "socket2", "tokio", - "tower-service", - "tracing", ] [[package]] @@ -750,29 +763,12 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "ipnet" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" - [[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -820,12 +816,6 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "mach2" version = "0.4.3" @@ -869,10 +859,16 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags 2.11.0", "cfg-if", - "cfg_aliases 0.1.1", + "cfg_aliases", "libc", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "once_cell" version = "1.21.4" @@ -927,21 +923,28 @@ dependencies = [ "cranelift-native", "futures-channel", "futures-util", + "http-body-util", + "hyper", + "hyper-util", "libc", "paste", "pd-edge-abi", "pd-host-function 0.1.0", + "rcgen", "regex", - "reqwest", "rt-format", "rusqlite", + "rustls", "rustyline", "self_cell", "serde", "serde_json", "syn 2.0.117", "tokio", + "tokio-rustls", + "tokio-tungstenite", "url", + "webpki-roots", "windows-sys 0.59.0", ] @@ -962,6 +965,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -989,6 +1002,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -998,62 +1017,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases 0.2.2", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases 0.2.2", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - [[package]] name = "quote" version = "1.0.44" @@ -1097,12 +1060,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rand_pcg" -version = "0.10.2" +name = "rcgen" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ - "rand_core", + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", ] [[package]] @@ -1160,47 +1127,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots", -] - [[package]] name = "ring" version = "0.17.14" @@ -1278,7 +1204,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ - "web-time", "zeroize", ] @@ -1300,12 +1225,6 @@ dependencies = [ "pd-vm", ] -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - [[package]] name = "rustyline" version = "14.0.0" @@ -1328,12 +1247,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "self_cell" version = "1.2.2" @@ -1384,15 +1297,14 @@ dependencies = [ ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "sha1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -1467,15 +1379,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - [[package]] name = "synstructure" version = "0.13.2" @@ -1495,18 +1398,18 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -1514,29 +1417,33 @@ dependencies = [ ] [[package]] -name = "tinystr" -version = "0.8.3" +name = "time" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ - "displaydoc", - "zerovec", + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", ] [[package]] -name = "tinyvec" -version = "1.12.0" +name = "time-core" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] -name = "tinyvec_macros" -version = "0.1.1" +name = "tinystr" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] [[package]] name = "tokio" @@ -1576,87 +1483,44 @@ dependencies = [ ] [[package]] -name = "tokio-util" -version = "0.7.19" +name = "tokio-tungstenite" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", + "futures-util", + "log", "tokio", + "tungstenite", ] [[package]] -name = "tower" -version = "0.5.3" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "tower-http" -version = "0.6.11" +name = "tungstenite" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ - "bitflags 2.11.0", "bytes", - "futures-util", + "data-encoding", "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", + "httparse", + "log", + "rand", + "sha1", + "thiserror", ] [[package]] -name = "try-lock" -version = "0.2.5" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -1733,74 +1597,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasmtime-internal-core" version = "42.0.1" @@ -1822,26 +1618,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "web-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-roots" version = "1.0.9" @@ -1954,6 +1730,15 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index f7a34ef5..3ac72789 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,18 @@ name = "vm" [features] default = ["runtime", "cli", "cranelift-jit"] runtime = [] -async = ["runtime", "dep:reqwest", "dep:url", "dep:tokio", "dep:futures-util"] -http-client = ["async"] +async = ["runtime", "dep:tokio", "dep:futures-util"] +http-client = [ + "async", + "dep:http-body-util", + "dep:hyper", + "dep:hyper-util", + "dep:rustls", + "dep:tokio-rustls", + "dep:tokio-tungstenite", + "dep:url", + "dep:webpki-roots", +] sqlite = ["runtime", "dep:rusqlite"] edge-abi = [ "dep:edge_abi", @@ -63,11 +73,17 @@ cranelift-jit = { version = "0.129.1", optional = true } 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" } -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"], optional = true } +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 } +tokio-tungstenite = { version = "0.30", default-features = false, features = ["handshake"], optional = true } +webpki-roots = { version = "1", optional = true } rusqlite = { version = "0.32", default-features = false, features = ["bundled", "hooks", "limits"], 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"], optional = true } +tokio = { version = "1", features = ["macros", "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" @@ -86,6 +102,7 @@ libc = "0.2" [dev-dependencies] futures-util = "0.3" +rcgen = "0.13" syn = { version = "2", features = ["full"] } tokio = { version = "1", features = ["macros", "rt", "time", "sync"] } @@ -99,6 +116,21 @@ 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 = "host_stream_callback_tests" +path = "tests/vm/host_stream_callback_tests.rs" +required-features = ["runtime"] + +[[test]] +name = "http_websocket_tests" +path = "tests/vm/http_websocket_tests.rs" +required-features = ["runtime", "http-client"] + [[test]] name = "sqlite_host_tests" path = "tests/vm/sqlite_host_tests.rs" diff --git a/README.md b/README.md index e9a15a75..46f34dc5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ The complete language, runtime, and implementation guides live on the [RustScrip - [RSS language](https://rustscript.org/docs/reference/rss/) - [Host functions](https://rustscript.org/docs/reference/host-functions/) - [Runtime controls and artifacts](https://rustscript.org/docs/reference/runtime-controls/) +- [Callable-driven HTTP client contract](docs/http-client.md) +- [Script call frames and callable values](docs/callable-runtime.md) - [Compiler frontend syntax and feature support](src/compiler/frontends/README.md) ## Crate usage diff --git a/build.rs b/build.rs index ce572c4f..f1d5ac48 100644 --- a/build.rs +++ b/build.rs @@ -162,12 +162,22 @@ fn main() { category: SourceCategory::DefaultHost, }, ]; - if env::var_os("CARGO_FEATURE_ASYNC").is_some() { + if env::var_os("CARGO_FEATURE_HTTP_CLIENT").is_some() { host_sources.push(SourceSpec { - path: "src/builtins/runtime/http.rs".to_string(), + 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/websocket.rs".to_string(), + module: "http::websocket".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, + }); } if env::var_os("CARGO_FEATURE_SQLITE").is_some() { host_sources.push(SourceSpec { @@ -1208,9 +1218,9 @@ fn render_callable_consts(callables: &[&CallableDecl]) -> String { for param in &callable.params { writeln!( &mut out, - " CallableParam {{ name: {:?}, ty: CallableParamType::{}, optional: {} }},", + " CallableParam {{ name: {:?}, ty: {}, optional: {} }},", param.name, - callable_param_variant(¶m.ty_label), + callable_param_expr(¶m.ty_label), param.optional ) .unwrap(); @@ -1633,18 +1643,38 @@ fn callable_const_base(callable: &CallableDecl) -> String { to_shouty_snake(&format!("{prefix}_{}", callable.rust_ident)) } -fn callable_param_variant(label: &str) -> &'static str { +pub(crate) fn callable_param_expr(label: &str) -> String { match label { - "any" => "Any", - "null" => "Null", - "int" => "Int", - "float" => "Float", - "bool" => "Bool", - "string" => "String", - "bytes" => "Bytes", - "array" => "Array", - "map" => "Map", - "number" => "Number", + "any" => "CallableParamType::Any".to_string(), + "null" => "CallableParamType::Null".to_string(), + "int" => "CallableParamType::Int".to_string(), + "float" => "CallableParamType::Float".to_string(), + "bool" => "CallableParamType::Bool".to_string(), + "string" => "CallableParamType::String".to_string(), + "bytes" => "CallableParamType::Bytes".to_string(), + "array" => "CallableParamType::Array".to_string(), + "map" => "CallableParamType::Map".to_string(), + "number" => "CallableParamType::Number".to_string(), + other if other.starts_with("fn(") => { + let (params, result) = other + .strip_prefix("fn(") + .and_then(|value| value.split_once(") -> ")) + .unwrap_or_else(|| panic!("invalid callable schema '{other}'")); + let params = if params.is_empty() { + Vec::new() + } else { + params + .split(", ") + .map(callable_param_expr) + .collect::>() + }; + let result = callable_param_expr(result); + format!( + "CallableParamType::Callable(CallableType {{ params: &[{}], return_type: &{} }})", + params.join(", "), + result + ) + } other => panic!("unsupported callable param type '{other}'"), } } @@ -2070,7 +2100,7 @@ fn static_return_type_label(output: &ReturnType) -> String { value_type_from_label(&return_type_label(output)).to_string() } -fn type_label(ty: &Type) -> String { +pub(crate) fn type_label(ty: &Type) -> String { match ty { Type::Group(group) => type_label(&group.elem), Type::Paren(paren) => type_label(&paren.elem), @@ -2108,6 +2138,7 @@ fn type_label(ty: &Type) -> String { "Array" | "VmArray" | "VmArrayRef" | "VmArrayHandle" => "array".to_string(), "Map" | "VmMap" | "VmMapRef" | "VmMapHandle" => "map".to_string(), "Number" | "NumberValue" => "number".to_string(), + "VmCallable" => callable_type_label(segment), "Unknown" | "UnknownValue" => "unknown".to_string(), "CallOutcome" => "unknown".to_string(), "Option" => { @@ -2136,6 +2167,25 @@ fn type_label(ty: &Type) -> String { } } +fn callable_type_label(segment: &syn::PathSegment) -> String { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + panic!("VmCallable requires a function signature"); + }; + let Some(syn::GenericArgument::Type(Type::BareFn(function))) = args.args.first() else { + panic!("VmCallable requires fn(...) -> ..."); + }; + let params = function + .inputs + .iter() + .map(|input| type_label(&input.ty)) + .collect::>(); + let result = match &function.output { + ReturnType::Default => "null".to_string(), + ReturnType::Type(_, ty) => type_label(ty), + }; + format!("fn({}) -> {result}", params.join(", ")) +} + fn type_label_for_vec(segment: &syn::PathSegment) -> String { let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { panic!("Vec requires one generic argument"); @@ -2310,3 +2360,26 @@ fn find_matching_paren(source: &str) -> usize { } panic!("unterminated macro invocation"); } + +#[cfg(test)] +mod callable_schema_tests { + use super::*; + use syn::parse_quote; + + #[test] + fn build_metadata_renders_typed_callable_parameters() { + let ty: Type = parse_quote!(VmCallable VmMap>); + assert_eq!(type_label(&ty), "fn(map) -> map"); + assert_eq!( + callable_param_expr("fn(map) -> map"), + "CallableParamType::Callable(CallableType { params: &[CallableParamType::Map], return_type: &CallableParamType::Map })" + ); + + let float_ty: Type = parse_quote!(VmCallable f64>); + assert_eq!(type_label(&float_ty), "fn(float) -> float"); + assert_eq!( + callable_param_expr("fn(float) -> float"), + "CallableParamType::Callable(CallableType { params: &[CallableParamType::Float], return_type: &CallableParamType::Float })" + ); + } +} diff --git a/crates/rustscript/tests/alias_smoke.rs b/crates/rustscript/tests/alias_smoke.rs index 5d6f0494..9d5ef7ff 100644 --- a/crates/rustscript/tests/alias_smoke.rs +++ b/crates/rustscript/tests/alias_smoke.rs @@ -53,6 +53,44 @@ fn alias_exports_public_invocation_stream_contract() { }); } +#[cfg(feature = "runtime")] +#[test] +fn alias_exports_public_host_callable_stream_embedding_api() { + use rustscript::{HostStreamAction, HostStreamDriver, HostStreamPoll, Vm}; + + struct CompileOnlyDriver; + + impl HostStreamDriver for CompileOnlyDriver { + fn poll_next( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + unreachable!("compile-only API smoke") + } + + fn apply_action( + &mut self, + _action: rustscript::Value, + ) -> rustscript::VmResult { + unreachable!("compile-only API smoke") + } + } + + fn submit( + vm: &mut Vm, + callback: rustscript::Value, + driver: CompileOnlyDriver, + ) -> rustscript::VmResult { + vm.submit_callable_stream(callback, driver) + } + + let _submit: fn( + &mut Vm, + rustscript::Value, + CompileOnlyDriver, + ) -> rustscript::VmResult = submit; +} + #[cfg(feature = "http-client")] #[test] fn alias_http_client_includes_runtime_contract() { diff --git a/docs/callable-runtime.md b/docs/callable-runtime.md index 217c3668..0a1d1be1 100644 --- a/docs/callable-runtime.md +++ b/docs/callable-runtime.md @@ -78,6 +78,14 @@ PDRC recordings preserve full execution-frame metadata. Callable environments us Polling drives execution and provides backpressure: at most one event item is buffered between polls, and the VM does not produce items while the consumer is not polling. `stream::emit` validates only the configured per-item value bound; sequence assignment, receipts, persistence, and delivery policy belong to the embedding. At most one invocation is active per VM, `Invocation::cancel(reason)` cancels with a typed `CancellationReason`, and the low-level `Vm::run` pump is unchanged for custom drivers. +## Callable-driven HTTP streams + +With the `http-client` feature, `http::client::request(request)`, `http::client::sse(request, on_event)`, and `http::client::websocket(request, on_event)` are script-facing host imports. The two streaming calls are long-running ordinary host calls. Each handler has the schema `fn(map) -> map`. The host produces one protocol item, the VM runs one child callback frame, and the returned action controls continuation or a WebSocket write before another item can arrive at the VM boundary. + +The callback may yield or wait in an ordinary async host call. Existing frame machinery resumes the callback first and returns its final action to the suspended HTTP call. The network future does not own or enter the VM and is not polled while the callback is active, so at most one item remains unacknowledged and callback completion supplies backpressure. + +Each buffered, SSE, and WebSocket import is an independent capability. Streaming calls expose no script request IDs, handles, detached resources, `next`, `close`, or cancellation callables. Their complete event maps, action maps, terminal summaries, bounds, destination policy, and lifecycle contract are documented in [HTTP client callable contract](http-client.md). + ## Optimized backends Whole-program AOT and Trace JIT use the same builtin call path (static catalog IDs) for environment binding, native frame dispatch for `callvalue`, and prototype-direct native dispatch for `callscript`. Script-frame entry and return preserve frame-relative locals and typed continuations. diff --git a/docs/http-client.md b/docs/http-client.md new file mode 100644 index 00000000..57672186 --- /dev/null +++ b/docs/http-client.md @@ -0,0 +1,300 @@ +# HTTP client callable contract + +RustScript exposes buffered HTTP, SSE, and WebSocket as bounded host imports. Streaming calls keep one ordinary host call active and invoke a script callable for each protocol item; they expose no response stream or socket object. + +The embedding must configure destination policy and grant each available callable explicitly. HTTP configuration and capability bindings are snapshotted when a call is admitted, so later profile or configuration changes cannot widen an active connection. + +All three APIs described below are available with the `http-client` feature. + +## Capabilities and profiles + +The three imports are independent capabilities: + +- `http::client::request` +- `http::client::sse` +- `http::client::websocket` + +Granting `http::client::request` does not grant either streaming protocol, and granting one streaming protocol does not grant the other. A restricted host-function profile must allow every imported callable used by the program. Profiles remain isolated: a grant or configuration in one VM/profile does not authorize another. + +Each available API is a host import gated by the HTTP client feature. The complete three-import contract does not consume or change static builtin IDs. See [Script call frames and callable values](callable-runtime.md) for callable execution and backend behavior. + +## Buffered requests + +```rust +use http; +use bytes; + +let response = http::client::request({ + "method": "POST", + "url": "https://example.test/v1/messages", + "headers": {"content-type": "application/json"}, + "body": bytes::from_utf8("{}"), +}); +``` + +`http::client::request(request)` accepts a map with: + +- `method`: one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, or `OPTIONS`; +- `url`: an `http` or `https` URL admitted by host policy; +- `headers`: an optional string-to-string map; +- `body`: optional bytes or a string. + +The response is buffered under the configured response-body limit and returned as: + +```rust +{ + "status": 200, + "headers": {"content-type": "application/json"}, + "body": bytes, + "url": "https://example.test/v1/messages", +} +``` + +`url` is the final validated URL after redirects. The request body, response body, response head, redirect count, concurrent connection count, connect phase, and total request duration are bounded. `Host`, `Content-Length`, `Transfer-Encoding`, and `Connection` are client-managed request headers. A limit, policy, transport, TLS, redirect, or timeout failure is a host error and produces no response map. + +## Server-sent events + +`http::client::sse` is available with the `http-client` feature. + +```rust +fn on_sse(item: map) -> map { + if item["kind"] == "event" { + print(item["data"]); + } + return {"action": "continue"}; +} + +let result = http::client::sse({ + "method": "GET", + "url": "https://example.test/events", + "headers": {"accept": "text/event-stream"}, +}, on_sse); +``` + +`http::client::sse(request, on_event)` uses this request map: + +| Field | Required | Accepted type and value | Bound or policy | +| --- | --- | --- | --- | +| `method` | yes | string: `GET` or `POST` | Other methods are rejected before transport admission | +| `url` | yes | string containing an `http` or `https` URL | Protocol family and the configured scheme, host, port, and address policy must all admit it | +| `headers` | no | map from string header names to string values | Names and values must be syntactically valid; client-managed request headers remain forbidden, and `Accept: text/event-stream` is supplied when absent | +| `body` | no | bytes or string, including for `POST` | Bounded by `max_request_body_bytes` | +| `timeout_ms` | no | positive integer milliseconds | Caps this optional shortening deadline by `HttpConfig::max_stream_duration` | + +The callback schema is `fn(map) -> map`, and the response must have an event-stream content type. The response head remains bounded by the existing HTTP parser. The contract adds no configurable request-header byte accounting. + +The callback receives exactly one map at a time, in this order: + +```rust +// The response was accepted; this precedes every event. +{ + "kind": "open", + "status": 200, + "headers": map, + "url": string, +} + +// One parsed event. "event" is per-dispatch state, reset to null at every +// dispatch boundary (including a blank line that dispatches no event); "id" +// and "retry_ms" are persistent stream state, retaining the last valid +// values seen so far and null only before any value has been seen. +{ + "kind": "event", + "event": string | null, + "data": string, + "id": string | null, + "retry_ms": int | null, +} + +// Clean EOF, after every preceding event callback completed. +{"kind": "end"} +``` + +The callback must return one of: + +```rust +{"action": "continue"} +{"action": "stop"} +``` + +`continue` acknowledges the item and permits the next network poll. `stop` ends the call locally. Any other shape or action is a callback error. + +SSE parsing follows the event-stream grammar: + +- UTF-8 text may start with one byte-order mark; +- `\r\n`, `\r`, and `\n` line endings are recognized; +- repeated `data:` fields are joined with `\n`, with the final join newline removed at dispatch; +- `event`, `id`, and decimal non-negative `retry` fields are normalized into the event map; +- comments and unknown fields are ignored; +- a blank line dispatches only after at least one `data:` field; +- malformed UTF-8, an over-limit line or event, and cumulative received event-stream application bytes exceeding the call limit are host errors. + +There is no automatic reconnection. Values such as a provider's `[DONE]` marker remain ordinary event data. + +## WebSocket sessions + +`http::client::websocket` is available with the `http-client` feature. + +```rust +fn on_socket(item: map) -> map { + if item["kind"] == "open" { + return {"action": "send_text", "text": "hello"}; + } + if item["kind"] == "text" && item["text"] == "done" { + return {"action": "close", "code": 1000, "reason": "complete"}; + } + return {"action": "continue"}; +} + +let result = http::client::websocket({ + "url": "wss://example.test/socket", + "headers": {"authorization": "Bearer ..."}, + "protocols": ["example.v1"], +}, on_socket); +``` + +`http::client::websocket(request, on_event)` uses this request map: + +| Field | Required | Accepted type and value | Bound or policy | +| --- | --- | --- | --- | +| `url` | yes | string containing a `ws` or `wss` URL | Protocol family and the configured scheme, host, port, and address policy must all admit it | +| `headers` | no | map from string header names to string values | Names and values must be syntactically valid; client-managed upgrade headers are rejected | +| `protocols` | no | array of syntactically valid subprotocol strings | The peer-selected subprotocol must match this offered list | +| `timeout_ms` | no | positive integer milliseconds | Caps this optional shortening deadline by `HttpConfig::max_stream_duration`, matching SSE | + +The callback schema is `fn(map) -> map`. The handshake response head remains bounded by the existing HTTP parser. The contract adds no configurable request-header byte accounting. + +After a validated `101` upgrade, the callback receives `open`, followed by incoming protocol items: + +```rust +{ + "kind": "open", + "status": 101, + "headers": map, + "url": string, + "protocol": string | null, +} +{"kind": "text", "text": string} +{"kind": "binary", "data": bytes} +{"kind": "ping", "data": bytes} +{"kind": "pong", "data": bytes} +{"kind": "close", "code": int | null, "reason": string} +``` + +The callback returns exactly one action map: + +```rust +{"action": "continue"} +{"action": "stop"} +{"action": "send_text", "text": string} +{"action": "send_binary", "data": bytes} +{"action": "ping", "data": bytes} +{"action": "pong", "data": bytes} +{"action": "close", "code": int, "reason": string} +``` + +Action rules depend on the current item: + +- `open`, `text`, and `binary` permit every declared action; +- on `ping`, `continue` sends the required pong with the same payload; explicit `pong`, `stop`, and `close` are also valid, while application-data sends are rejected; +- on `pong`, `continue`, `ping`, `stop`, and `close` are valid; +- peer `close` is delivered once. `continue` sends the matching acknowledgment, `close` may provide its code and reason, and `stop` drops locally; each action then terminates the call; +- `stop` drops the connection locally and does not synthesize a close callback; +- local `close` sends one close frame and waits only through the configured close-handshake timeout. + +Exactly one callback action is applied before another inbound item is polled. Fragmented text or binary frames are reassembled into one callback item under the message limit. Frame, message, control payload, outbound payload, and close code/reason limits apply. For WebSocket, `max_stream_total_bytes` counts combined text/binary application payload bytes sent and received across the entire call; frame and control overhead are bounded separately by protocol and frame caps. Invalid UTF-8 text, invalid actions, protocol violations, write failures, and abnormal transport EOF are host errors. + +## Terminal summaries and errors + +After callback processing terminates normally, either streaming call returns one summary: + +```rust +{ + "outcome": "eof" | "stopped" | "closed", + "status": int, + "headers": map, + "url": string, + "items": int, + "bytes_received": int, + "bytes_sent": int, +} +``` + +SSE normally reports `eof` or `stopped`; WebSocket may also report `closed` after peer/local close semantics complete. `items` counts delivered callback items. `bytes_received` and `bytes_sent` are observational summary counters. Limit enforcement uses independent entire-call accounting and does not depend on whether or how these counters are displayed. + +Transport, parser, destination-policy, timeout, and callback failures stay errors. They are never converted into a successful terminal summary. + +## Sequencing, backpressure, and lifecycle + +Streaming is a single caller-owned operation: + +1. the host polls for one protocol item; +2. the VM invokes `on_event` in a child script frame; +3. the callback returns one action; +4. the host applies that action before polling for another item. + +At most one unacknowledged protocol item crosses the host/VM boundary. Decoder scratch space is bounded separately. The network future is not polled while the callback runs, yields, or waits in another async host call. If the callback yields or invokes an ordinary async host function, the callback resumes first; only its final action resumes the outer stream operation. This sequencing supplies backpressure without a background reader or callback queue. + +The network future never owns or re-enters the VM. Callback error, protocol completion, configured deadline, VM reset/shutdown/drop, invocation termination, or normal return retires the operation exactly once. The embedding owns pending futures: retiring a call drops its transport and permit, and a late completion cannot re-enter the VM. + +`request_timeout` is the total bound for a buffered request and does not apply to streaming. `max_stream_duration` is the host-controlled absolute total-duration bound for each SSE and WebSocket call. Both protocols compute one admission-time deadline from the smaller of `max_stream_duration` and optional positive `timeout_ms`; the script value can only shorten the call and cannot disable or extend the host maximum. DNS, TCP, TLS, handshake, active reads and writes, callback execution, callback waits, and close flushing all count against the same deadline. Embedding invocation retirement may still terminate either protocol sooner. `stream_idle_timeout` remains a separate wait-for-network-progress bound and resets only after progress; periodic traffic cannot extend the total deadline. Network idle time excludes time spent inside the callback, while callback work remains inside the total deadline. WebSocket close waiting is additionally bounded by `websocket_close_timeout`, which cannot extend the total deadline. + +## Configuration defaults + +`HttpConfig` uses explicit bounded defaults. Streaming byte limits and all timeout fields must remain positive: + +| Field | Default | Purpose | +| --- | ---: | --- | +| `allowed_schemes` | `https`, `wss` | Scheme allowlist; protocol-family checks still apply | +| `allowed_hosts` | empty | Destination host allowlist; empty denies every host | +| `allowed_ports` | empty | Destination port allowlist; empty denies every port | +| `allow_private_ips` | `false` | Reject private and other special-use addresses | +| `max_redirects` | 5 | Buffered/SSE redirect bound | +| `max_request_body_bytes` | 1 MiB | Request body bound | +| `max_response_body_bytes` | 8 MiB | Buffered response body bound | +| `connect_timeout` | 10 s | DNS/connect/TLS phase bound | +| `request_timeout` | 30 s | Buffered request total duration | +| `max_stream_item_bytes` | 1 MiB | SSE event or WebSocket message bound | +| `max_stream_total_bytes` | 64 MiB | Entire-call cumulative application-byte bound: received event-stream bytes for SSE; sent plus received application payload for WebSocket | +| `max_sse_line_bytes` | 64 KiB | SSE line bound | +| `max_websocket_frame_bytes` | 1 MiB | WebSocket frame bound | +| `max_websocket_send_bytes` | 1 MiB | One WebSocket outbound action bound | +| `max_stream_duration` | 5 min | Host maximum total duration for SSE and WebSocket calls | +| `stream_idle_timeout` | 30 s | Wait-for-network-data bound | +| `websocket_close_timeout` | 5 s | Close-handshake wait bound | + +The shared in-flight connection default is 64. Zero values for streaming byte limits or any timeout are invalid configuration; buffered `max_request_body_bytes` and `max_response_body_bytes` may be zero to prohibit request or response payload bytes. `HttpConfig::default()` allows `https` and `wss`. Embeddings should set explicit host and port allowlists and add `http` or `ws` only when those schemes are required. Buffered HTTP and SSE accept only `http`/`https`; WebSocket accepts only `ws`/`wss`. + +## Destination policy and protocol transports + +Every protocol uses the same admission, address-pinning, and security policy: + +- URLs require a host and reject userinfo; +- both the protocol's scheme family and the configured scheme allowlist must admit the URL; +- host and effective port must match their configured allowlists; +- every DNS result is validated, and the selected validated address is pinned for the connection; +- when private addresses are disabled, private, loopback, link-local, multicast, unspecified, documentation, transition, reserved, and other special-use IPv4/IPv6 ranges are rejected; IPv4-mapped IPv6 addresses receive the IPv4 checks; +- the original validated hostname remains the TLS SNI name and HTTP `Host` authority when connecting to a pinned address; +- buffered HTTP and SSE revalidate every redirect and remove `Authorization` and `Cookie` on a cross-origin redirect; +- WebSocket does not automatically redirect. Any handshake path that supports redirects must route every hop through the same validation and cross-origin credential stripping before reconnecting; +- ambient proxy settings are ignored. There is no implicit cookie jar, authentication source, or global proxy state. + +The policy snapshot taken at call admission applies for the complete operation. + +Protocol transport remains separate from that shared policy. Buffered HTTP and SSE use direct Hyper HTTP/1 over Tokio/Rustls connections. WebSocket uses Tungstenite over a prevalidated, pinned Tokio/Rustls stream and performs no independent DNS lookup or connection outside the shared admission and pinning path. + +## Deliberately absent APIs and semantics + +RustScript core provides no script-visible HTTP request ID, response/stream/socket handle, `next`, `next_event`, `next_message`, `close`, or `cancel` callable. Streams cannot detach from their caller. There is no multiplexing, background reader, automatic reconnect, provider/model interpretation, agent loop, or platform retry policy. Applications implement provider-specific JSON, `[DONE]`, tool-call deltas, retry rules, and reconnect decisions in RSS or downstream hosts. + +## Cancellation migration + +PR #13 introduced HTTP-private pending-operation and abort-handle maps, one abort pair per request, HTTP owner routes, request-local runtimes, and HTTP-synthesized cancellation errors. The callable streaming contract supersedes those mechanisms. Buffered requests, SSE, and WebSocket submit ordinary futures through the embedding-owned async bridge; HTTP has no private pending map, abort map, operation-ID namespace, token owner route, or cancellation state machine. + +The generic `src/builtins/runtime/cancellation.rs` remains for non-HTTP runtime callers. HTTP does not depend on `CancellationToken`, `CancellationReason`, `OperationOwner::Http`, or owner-wide cancellation routing. Embedding-owned retirement of a pending future remains VM lifecycle control and rejects late completion; dropping an `Invocation` also retires active producer/callback waits and returns the VM and connection permit for reuse. This lifecycle cleanup is not an HTTP API-level cancellation facility. + +## Target and backend notes + +The callable pump follows the ordinary host-call suspension boundary for interpreter, Trace JIT, and whole-program AOT execution. Network futures remain outside VM execution, and callback frames use the same wait/yield continuation rules across backends. + +`pd-vm-nostd` retains callable metadata and static builtin IDs without including HTTP transport implementations. WebAssembly and other embeddings can expose host imports only when that embedding supplies the capability, policy configuration, and async driving required by this contract. The contract does not imply an HTTP backend on targets where the host has not provided one. diff --git a/pd-host-function/src/lib.rs b/pd-host-function/src/lib.rs index fb4f8f96..a0aa6e38 100644 --- a/pd-host-function/src/lib.rs +++ b/pd-host-function/src/lib.rs @@ -531,6 +531,7 @@ fn type_label(ty: &Type) -> Result { "Array" | "VmArray" | "VmArrayRef" | "VmArrayHandle" => Ok("array".to_string()), "Map" | "VmMap" | "VmMapRef" | "VmMapHandle" => Ok("map".to_string()), "Number" | "NumberValue" => Ok("number".to_string()), + "VmCallable" => callable_type_label(segment), "Unknown" | "UnknownValue" => Ok("unknown".to_string()), "CallOutcome" => Ok("unknown".to_string()), "Option" => { @@ -575,6 +576,31 @@ fn type_label(ty: &Type) -> Result { } } +fn callable_type_label(segment: &syn::PathSegment) -> Result { + let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { + return Err(Error::new_spanned( + &segment.arguments, + "VmCallable requires a function signature", + )); + }; + let Some(syn::GenericArgument::Type(Type::BareFn(function))) = args.args.first() else { + return Err(Error::new_spanned( + args, + "VmCallable requires fn(...) -> ...", + )); + }; + let params = function + .inputs + .iter() + .map(|input| type_label(&input.ty)) + .collect::, _>>()?; + let result = match &function.output { + ReturnType::Default => "null".to_string(), + ReturnType::Type(_, ty) => type_label(ty)?, + }; + Ok(format!("fn({}) -> {result}", params.join(", "))) +} + fn type_label_for_vec(segment: &syn::PathSegment) -> Result { let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { return Err(Error::new_spanned( @@ -672,8 +698,8 @@ fn uses_taken_extractor(ty: &Type) -> bool { #[cfg(test)] mod tests { - use super::expand_pd_host_function; - use syn::{ItemFn, Meta, Token, parse_quote, punctuated::Punctuated}; + use super::{expand_pd_host_function, type_label}; + use syn::{ItemFn, Meta, Token, Type, parse_quote, punctuated::Punctuated}; #[test] fn accepts_host_call_result_from_the_function_signature() { @@ -779,4 +805,23 @@ mod tests { .contains("parameters must be owned and 'static") ); } + + #[test] + fn callable_wrapper_preserves_parameter_and_result_schema() { + let ty: Type = parse_quote!(VmCallable VmMap>); + assert_eq!(type_label(&ty).unwrap(), "fn(map) -> map"); + let attr: Punctuated = parse_quote!(name = "test::stream"); + let item: ItemFn = parse_quote! { + /// Starts a synthetic callable stream. + fn stream(callback: VmCallable VmMap>) -> VmResult { + todo!() + } + }; + let expanded = expand_pd_host_function(attr, item).unwrap().to_string(); + assert!(expanded.contains("VmCallable < fn (VmMap) -> VmMap >")); + assert!(expanded.contains("borrow_arg")); + + let float_ty: Type = parse_quote!(VmCallable f64>); + assert_eq!(type_label(&float_ty).unwrap(), "fn(float) -> float"); + } } diff --git a/plans/2026-08-09_architecture-plan-index.md b/plans/2026-08-09_architecture-plan-index.md index 8cbc2085..d70be788 100644 --- a/plans/2026-08-09_architecture-plan-index.md +++ b/plans/2026-08-09_architecture-plan-index.md @@ -22,6 +22,7 @@ | Execution contract | Return/event ambiguity, buffered-only events, string errors, fragmented terminal state | `2026-08-09_run-outcome-event-error-contract.md` | RunContext; host lifecycle for final cancellation integration | | Authorization | Builtin fast-path bypass, mutable identity/cache complexity, Edge macro leakage | `2026-08-09_capability-profile-host-binding.md` | static IDs | | Async host transport | Core macro contains Edge scope knowledge; HTTP owns a synchronous scheduler; IO lacks feature-selected blocking/async bindings | `2026-08-09_http-transport-security-executor.md` | capability profile and host lifecycle | +| Streaming HTTP | Buffered HTTP cannot expose SSE/WebSocket items incrementally; a network future cannot re-enter the VM safely | `2026-08-12_callable-streaming-http-client.md` | async host transport, real script call frames/callable schemas, capability profile | | Structured concurrency | One waiting slot, no generic multi-operation/child-program supervisor | `2026-08-09_structured-task-supervisor.md` | VM decomposition, host lifecycle, capability profile | | Backend architecture | Repeated semantics across interpreter/JIT/AOT/native/no-std | `2026-08-09_backend-semantic-convergence.md` | static IDs, VM decomposition | @@ -66,9 +67,10 @@ Exit gate: production host subsystems use one lifecycle, and every invocation yi Can run in parallel after their dependencies: 1. Generic host-driven async ABI, IO dual implementation, and async-only HTTP transport security. -2. Structured task supervisor. -3. Backend semantic convergence. -4. Agent run lifecycle and durable state integration. +2. Callable-driven SSE and WebSocket after the async-host boundary and callable schemas are complete. +3. Structured task supervisor. +4. Backend semantic convergence. +5. Agent run lifecycle and durable state integration. ### Local-slot correction and capacity route @@ -90,6 +92,7 @@ Exit gate: separate frames reuse relative slots, direct-only named functions do - Structural plans remove superseded transitional paths after migration; they do not retain dual long-term architectures. - New generic host functions require their own implementation plans; this index covers the architecture findings already identified. - Async host futures are driven by the embedding host. VM, HTTP, and IO do not own a private executor or synchronous polling scheduler. +- Streaming HTTP is delivered by a one-item host-to-callable pump. Scripts receive no request ID, stream/socket handle, pull API, or HTTP cancellation callable. - Core host macros contain no pd-edge scopes, context types, registry generation, or downstream module paths. ## Target criteria diff --git a/plans/2026-08-09_http-transport-security-executor.md b/plans/2026-08-09_http-transport-security-executor.md index 75ec902e..47d102b0 100644 --- a/plans/2026-08-09_http-transport-security-executor.md +++ b/plans/2026-08-09_http-transport-security-executor.md @@ -1,5 +1,7 @@ # Async Host Transport Security Plan +> **HTTP streaming supersession (2026-08-12):** This plan remains authoritative for the generic host-driven async ABI, IO feature selection, buffered `http::client::request`, transport security, and VM-internal lifecycle cleanup. `2026-08-12_callable-streaming-http-client.md` supersedes any extension of this design to streaming HTTP. SSE and WebSocket use script callables and do not expose request handles, `next_*`, `close`, or cancellation APIs. + **Goal:** Make HTTP a host-driven async host function, provide feature-selected blocking/async IO implementations, and preserve transport security without any HTTP-owned scheduler. **Architecture:** `#[pd_host_function] async fn` produces a generic async host factory. The VM allocates an operation ID and hands the resulting `'static` future to the embedding's `HostAsyncBridge`; the host owns submission, waking, polling, cancellation, and reactor/executor integration. HTTP contains only policy, request construction, transport, redirect, deadline, and response decoding logic. Edge scopes and `SharedProxyVmContext` expansion remain owned by `pd-edge`. @@ -44,6 +46,7 @@ - A VM-owned Tokio runtime or process executor. - HTTP-specific scheduling infrastructure. - Provider JSON, retries, model selection, SSE semantic parsing, or agent loops. +- SSE/WebSocket delivery and their callable stream pump; these are owned by `2026-08-12_callable-streaming-http-client.md`. - Ambient proxy support by default. - Script-controlled policy relaxation. - Source-language futures or `await` syntax. @@ -131,6 +134,8 @@ The core proc-macro must retain only name-based generic sync/async host expansio ### Milestone 6: Cancellation and lifecycle convergence +This milestone defines VM/embedding cleanup of pending host work. It does not define a script-visible HTTP cancellation callable, request ID, or resource handle. Streaming HTTP follows the callable-controlled terminal contract in `2026-08-12_callable-streaming-http-client.md`. + 1. Propagate run cancellation/deadline/reset/drop to `HostAsyncBridge::cancel_op_with_reason`. 2. Ensure driver completion after a terminal run cannot re-enter the VM. 3. Verify cancellation during DNS, connect, response headers, and body streaming. diff --git a/plans/2026-08-12_callable-streaming-http-client.md b/plans/2026-08-12_callable-streaming-http-client.md new file mode 100644 index 00000000..ee7c3d16 --- /dev/null +++ b/plans/2026-08-12_callable-streaming-http-client.md @@ -0,0 +1,592 @@ +# Callable-Driven Streaming HTTP Client Implementation Plan + +**Goal:** Extend RustScript's HTTP client with SSE and WebSocket streaming through script callables, without exposing script-visible request handles, `next_*`, `close`, or cancellation APIs. + +**Architecture:** Keep `http::client::request(request)` as the bounded buffered request API. Add `http::client::sse(request, on_event)` and `http::client::websocket(request, on_event)` as long-running ordinary host calls. A generic VM stream pump alternates between polling one host-produced item and invoking one script callable; the callable's returned action controls continuation and WebSocket writes. Network futures never own or re-enter the VM, callback execution never polls the network stream, and the one-item handoff provides backpressure. + +**Tech Stack:** Rust 2024, RustScript callable values and typed callable schemas, `HostAsyncBridge`, Reqwest/Tokio, `futures-util`, `tokio-tungstenite`, local TCP/SSE/WebSocket fixtures. + +--- + +## Status and supersession + +This plan supersedes the HTTP transport portion of `2026-08-09_http-transport-security-executor.md` after that plan established the generic async-host boundary and migrated the buffered client. It preserves that plan's host-driven executor, feature gating, destination policy, DNS pinning, redirect, deadline, byte-limit, and lifecycle cleanup requirements. + +This plan deliberately removes script-visible cancellation from the HTTP client design. Internal VM lifecycle cleanup remains mandatory: reset, shutdown, drop, invocation termination, and configured deadlines must drop the stream operation and socket. That internal cleanup is not an HTTP callable and has no script-level request ID. + +## PR #13 cancellation disposition + +GitHub PR #13 (`feat(http): add a bounded cancellable host client`, head `475e5aa`) introduced the first HTTP client together with HTTP-private asynchronous operation ownership: + +- `HttpState::pending_ops` and `HttpState::abort_handles`; +- one `AbortHandle`/`Abortable` pair per request; +- HTTP-specific `cancel_pending_op` and `cancel_all_pending_ops` routing; +- a private thread and Tokio runtime per request; +- cancellation errors synthesized by the HTTP subsystem. + +Those mechanisms are superseded by this plan. Buffered HTTP, SSE, and WebSocket all submit ordinary futures through the embedding-owned async bridge. Reset, shutdown, drop, invocation termination, or deadline retirement removes and drops the submitted future; dropping the future releases the Reqwest response stream or WebSocket transport. HTTP does not retain a second pending-operation map, abort-handle map, operation-ID namespace, cancellation token tree, or cancellation error state machine. + +The later `src/builtins/runtime/cancellation.rs` did not originate in PR #13. It was added by the unified host-runtime lifecycle work and currently also serves Invocation, IO, SQLite, resources, and the generic host bridge. This plan has the following boundary: + +1. Remove every HTTP dependency on `CancellationToken`, `CancellationReason`, `OperationOwner::Http`, and `cancel_operations_by_owner`. +2. Use absolute protocol deadlines (`timeout_at`/equivalent), bounded idle timers, callback actions, protocol close/EOF, and future drop as HTTP terminal mechanisms. +3. Keep the embedding's ability to retire a pending future and reject a late completion. This is operation lifecycle control, not a script-visible HTTP cancellation facility. +4. Do not delete `cancellation.rs` while IO, SQLite, Invocation, resources, or the generic host bridge still import it. +5. After those non-HTTP callers are migrated, delete the generic cancellation-token tree and split any remaining responsibilities into run termination state, async-host operation identity, and resource-specific cleanup. That repository-wide simplification is a follow-up and must not be force-fitted into the HTTP transport implementation. + +pd-edge did not need `cancellation.rs` for its async HTTP hosts: its generated async wrapper schedules a future through `SharedVmAsyncOps`/`schedule_current_future_call`, and the embedding owns that future's lifecycle. The same ownership model is the reference for this plan. RustScript core still guarantees exactly one terminal transition and prevents a dropped operation from re-entering the VM; it does not require a cooperative token check inside every network stage. + +## Dependency order + +1. The current callable implementation must remain frame-aware and able to pass closures/named functions as values. +2. The current host-driven async ABI remains the only executor boundary. +3. Implement the generic callable stream pump before either protocol adapter. +4. Implement SSE before WebSocket; SSE validates ordered delivery and backpressure without duplex command handling. +5. WebSocket reuses the same pump and adds callback-returned outbound actions. + +No source-language `async`, iterator, generator, resource handle, or structured-task feature is required. + +## Script-facing contract + +### Buffered HTTP + +```rust +use http; + +let response = http::client::request({ + "method": "POST", + "url": "https://example.test/v1/messages", + "headers": {"content-type": "application/json"}, + "body": bytes::from_utf8("{}"), +}); +``` + +The existing response map stays stable: + +```rust +{ + "status": 200, + "headers": {"content-type": "application/json"}, + "body": bytes, + "url": "https://example.test/v1/messages", +} +``` + +### SSE + +```rust +fn on_sse(item: map) -> map { + if item["kind"] == "event" { + print(item["data"]); + } + return {"action": "continue"}; +} + +let result = http::client::sse({ + "method": "GET", + "url": "https://example.test/events", + "headers": {"accept": "text/event-stream"}, +}, on_sse); +``` + +The callback receives exactly one map at a time: + +```rust +// Response accepted, before the first event. +{ + "kind": "open", + "status": 200, + "headers": map, + "url": string, +} + +// One parsed SSE event. "event" is per-dispatch state, reset to null at +// every dispatch boundary (including a blank line that dispatches no +// event); "id" and "retry_ms" are persistent stream state, retaining the +// last valid values seen so far and null only before any value has been +// seen. +{ + "kind": "event", + "event": string | null, + "data": string, + "id": string | null, + "retry_ms": int | null, +} + +// Clean EOF after all preceding events were acknowledged by the callback. +{"kind": "end"} +``` + +SSE parsing follows the event-stream grammar: + +- UTF-8 text with an optional leading BOM; +- `\r\n`, `\r`, and `\n` line endings; +- repeated `data:` fields joined with `\n`, removing the final join newline at dispatch; +- `event`, `id`, and decimal non-negative `retry` fields; +- comment and unknown fields ignored; +- a blank line dispatches an event only when at least one `data:` field was seen; +- malformed UTF-8, an over-limit line, event, or cumulative stream is a host error; +- no automatic reconnection and no interpretation of provider-specific `[DONE]` values. + +### WebSocket + +```rust +fn on_socket(item: map) -> map { + if item["kind"] == "open" { + return {"action": "send_text", "text": "hello"}; + } + if item["kind"] == "text" && item["text"] == "done" { + return {"action": "close", "code": 1000, "reason": "complete"}; + } + return {"action": "continue"}; +} + +let result = http::client::websocket({ + "url": "wss://example.test/socket", + "headers": {"authorization": "Bearer ..."}, + "protocols": ["example.v1"], +}, on_socket); +``` + +The callback receives: + +```rust +{ + "kind": "open", + "status": 101, + "headers": map, + "url": string, + "protocol": string | null, +} +{"kind": "text", "text": string} +{"kind": "binary", "data": bytes} +{"kind": "ping", "data": bytes} +{"kind": "pong", "data": bytes} +{"kind": "close", "code": int | null, "reason": string} +``` + +The callback returns one explicit action map: + +```rust +{"action": "continue"} +{"action": "stop"} +{"action": "send_text", "text": string} +{"action": "send_binary", "data": bytes} +{"action": "ping", "data": bytes} +{"action": "pong", "data": bytes} +{"action": "close", "code": int, "reason": string} +``` + +Rules: + +- `stop` ends locally and drops the connection without synthesizing a close callback; +- `close` sends one close frame, waits up to the configured close timeout for the peer close, then returns; +- on `open`, `text`, and `binary`, every declared action is allowed; +- on `ping`, `continue` sends the protocol-required pong with the same payload; `pong`, `stop`, and `close` are also allowed, while application data sends are rejected; +- on `pong`, `continue`, `ping`, `stop`, and `close` are allowed; +- peer close is delivered once; `continue` sends the matching close acknowledgment, `close` may supply the acknowledgment code/reason, and `stop` drops locally; all three then return; +- control-frame payload, close-code, close-reason, message, frame, and cumulative-byte limits are enforced; +- fragmented frames are reassembled into one text/binary callback item under the configured message limit; +- invalid UTF-8 text, protocol violation, invalid callback action, write failure, or abnormal transport EOF is a host error; +- no reconnect, multiplexing, background reader, or script-visible socket object. + +### Return value + +Both streaming calls return one terminal summary map after callback processing ends: + +```rust +{ + "outcome": "eof" | "stopped" | "closed", + "status": int, + "headers": map, + "url": string, + "items": int, + "bytes_received": int, + "bytes_sent": int, +} +``` + +Transport, parser, policy, timeout, and callback errors remain errors; they are not converted into a successful summary. + +## Hard invariants + +1. No network future stores `&mut Vm`, `Store`, `ScriptCallback`, interpreter frame pointers, or a VM-owning closure. +2. No async future calls `Vm::invoke_callable`, `Vm::start_callable`, `Store::poll_callbacks`, or equivalent VM entry points. +3. At most one unacknowledged protocol item is retained between host and VM. Optional decoder scratch is bounded separately. +4. While a callback is running or waiting in another host call, the streaming network future is not polled. Callback completion is the backpressure acknowledgment. +5. The stream host call occupies the caller's existing pending call boundary; the callback runs as a child script frame and returns to that stream continuation. +6. The callback may yield or invoke ordinary async host functions. Nested waiting resumes the callback first, then returns its action to the stream pump. +7. Callback panic/error, VM shutdown, reset/drop, invocation termination, deadline, protocol terminal state, and normal completion retire the operation exactly once. +8. HTTP configuration and capability binding are snapshotted at call admission. Mutating later registry/config state cannot widen an active connection. +9. Existing `http::client::request` generated host identity, signature, result shape, and buffered semantics stay unchanged; it has no script-visible request ID. +10. Protocol-specific provider logic remains in RSS or downstream hosts. + +## Configuration additions + +Extend `HttpConfig` with explicit bounded defaults: + +```rust +pub max_stream_item_bytes: usize; // SSE event or WS message +pub max_stream_total_bytes: usize; // entire streaming call +pub max_sse_line_bytes: usize; +pub max_websocket_frame_bytes: usize; +pub max_websocket_send_bytes: usize; +pub max_stream_duration: Duration; // 5 minute host total-duration cap +pub stream_idle_timeout: Duration; +pub websocket_close_timeout: Duration; +``` + +`request_timeout` remains the total duration for buffered requests. Streaming calls have a positive `max_stream_duration` host limit, defaulting to 5 minutes. At admission, compute one absolute deadline from the smaller of that host limit and optional positive request-map `timeout_ms`; the script cannot disable or extend the host limit. The SSE implementation enforces this deadline while opening and reading. Milestone 6 WebSocket integration must apply the same field and capping rule. Embedding invocation retirement may still terminate a call sooner, and idle timeout remains a separate progress-based bound. + +Allowed schemes are protocol-aware: + +- buffered request and SSE accept `http`/`https` only; +- WebSocket accepts `ws`/`wss` only; +- `https` and `wss` remain the default public-network schemes; +- host/port/address policy is shared across all four schemes. + +## Implementation route + +### Milestone 1: Freeze callable-pump semantics with RED tests + +**Objective:** Specify VM alternation, callback suspension, result delivery, and cleanup before transport code exists. + +**Files:** +- Create: `tests/vm/host_stream_callback_tests.rs` +- Modify: `Cargo.toml` +- Modify: `src/vm/tests.rs` +- Modify: `src/vm/instance.rs` + +**Tests:** + +1. A synthetic host stream delivers three maps to a closure in order and returns its summary. +2. The producer is polled only after the preceding callback returns `continue`. +3. A callback that enters `VmStatus::Waiting` resumes and returns an action without corrupting the outer stream call frame. +4. A callback that yields resumes before the producer is polled again. +5. `stop` prevents a queued fourth producer item from being observed. +6. Callback type mismatch and invalid action abort the stream call with no stale frame, item, or operation. +7. reset/shutdown/drop from producer-wait and callback-wait phases release the operation and callable once. +8. Interpreter, JIT, and AOT enter the same stream continuation through the ordinary host-call boundary. + +**RED command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream \ + cargo test --locked --test host_stream_callback_tests --all-features +``` + +Expected: compile/test failure because the generic stream continuation does not exist. + +**Commit:** `test(vm): define callable stream pump contract` + +### Milestone 2: Add typed callable host parameters + +**Objective:** Preserve `fn(map) -> map` in host metadata and reject incompatible handlers before network admission. + +**Files:** +- Modify: `pd-host-function/src/lib.rs` +- Modify: `build.rs` +- Modify: `src/compiler/parser/symbols.rs` +- Modify: host callable metadata/signature structures generated by `build.rs` +- Modify: `tests/host_binding_generation_tests.rs` +- Modify: `tests/compiler/compiler_rustscript_tests.rs` + +**Implementation:** + +1. Add an owned callable argument wrapper used only for synchronous admission, for example `VmCallable`, whose extracted value must be `Value::Callable`. +2. Teach proc-macro and build-time type-label generation to encode callable parameters rather than `any`; include parameter and result schemas in `HostCallableSignature`. +3. Declare both streaming APIs as accepting `VmCallable map>` or equivalent generated schema metadata. +4. Reuse compiler contextual callable typing so named generic functions and closures specialize against the expected callback schema. +5. At binding/admission validate callable kind, arity, parameter schema, and result schema. Dynamic/unknown callables still receive runtime result validation. +6. Async wrappers may move the callable value into VM-owned stream state, but must not move it into the network future. + +**Tests:** + +- correct closure and named function compile; +- wrong arity, parameter type, and return type fail with callable-specific diagnostics; +- runtime-created unknown callable with a non-map action fails before a second producer poll; +- generated docs show `fn(map) -> map` for both APIs. + +**Commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream \ + cargo test --locked -p pd-host-function +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream \ + cargo test --locked --test host_binding_generation_tests --all-features +``` + +**Commit:** `feat(host): preserve callable parameter schemas` + +### Milestone 3: Implement the generic host-to-callable stream pump + +**Objective:** Alternate one host item and one script callback using VM-owned continuation state. + +**Files:** +- Create: `src/vm/async_host/stream.rs` +- Modify: `src/vm/async_host/mod.rs` +- Modify: `src/vm/instance.rs` +- Modify: `src/vm/mod.rs` +- Modify: `src/vm/host.rs` +- Modify: `src/vm/host_runtime.rs` +- Modify: `src/vm/native/bridge.rs` +- Modify: JIT/AOT host-call exit handling only where conformance tests expose a gap +- Modify: `tests/vm/host_stream_callback_tests.rs` + +**Core state machine:** + +```text +AwaitItem + -> ItemReady(item) + -> RunCallback(item) + -> CallbackWaiting | CallbackYielded | ActionReady(action) + -> ApplyAction(action) + -> AwaitItem | Complete(summary) | Error +``` + +**Implementation:** + +1. Introduce a generic host stream operation trait/envelope whose driver poll returns exactly one of `Pending`, `Item(Value)`, `Complete(Value)`, or `Error(VmError)`. +2. Store the callback value, current item, phase, and parent host-call continuation in VM instance state; store transport/decoder state in the host driver. +3. Extend host-operation output with a stream item boundary. Do not represent each item as a completed operation ID. +4. When an item arrives, start the callable with `FrameContinuation::ReturnToHost` adapted to resume the stream state rather than halting the whole outer call. +5. If the callable waits or yields, retain the outer stream continuation and resume the callable through existing VM machinery. +6. Validate the returned map through a protocol-supplied action decoder, apply the action to the driver, then permit the next producer poll. +7. On every terminal path, remove stream state, callback, item, pending write, and operation ownership exactly once. +8. Keep ordinary async host futures and invocation item streams unchanged. + +**GREEN command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream \ + cargo test --locked --test host_stream_callback_tests --all-features +``` + +Expected: all callable-pump contract tests pass. + +**Commit:** `feat(vm): add host-driven callable stream pump` + +### Milestone 4: Factor shared HTTP connection policy + +**Objective:** Make buffered HTTP, SSE, and WebSocket use one admission and connection-security path. + +**Files:** +- Create: `src/builtins/runtime/http/config.rs` +- Create: `src/builtins/runtime/http/policy.rs` +- Create: `src/builtins/runtime/http/request.rs` +- Move/Modify: `src/builtins/runtime/http.rs` to `src/builtins/runtime/http/mod.rs` +- Modify: `src/builtins/runtime/cancellation.rs` +- Modify: `build.rs` +- Modify: `src/builtins/runtime/mod.rs` +- Modify: `tests/vm/http_host_tests.rs` + +**Implementation:** + +1. Move request parsing, header restrictions, URL/userinfo validation, DNS lookup, special-address rejection, address pinning, redirect validation, credential stripping, permit accounting, and deadline helpers into shared modules. +2. Parameterize allowed scheme families so all protocols use the same host/port/address decisions. +3. Add the bounded streaming configuration fields and validation; zero limits are rejected during configuration. +4. Preserve buffered request behavior byte-for-byte, including final URL and response header conversion. +5. Keep ambient proxies disabled. Do not add cookie jars, automatic auth, or global connection state. +6. Add policy tests proving `ws`/`wss` cannot bypass host, port, private-address, or DNS pinning rules. +7. Gate HTTP runtime modules, dependencies, generated metadata, and registration on `http-client`; `async` alone must not publish HTTP callables. +8. Replace the current single-file `build.rs` HTTP source entry with explicit `CARGO_FEATURE_HTTP_CLIENT` DefaultHost source entries for `http/mod.rs`, `http/sse.rs`, and `http/websocket.rs`; build-time callable discovery is not recursive. +9. Remove `HttpRequestContext.cancellation`, HTTP `CancellationToken` parameters/checks, `OperationOwner::Http`, and HTTP owner-wide cancellation routing. Do not create replacement HTTP abort handles or a private pending-operation registry. +10. Wrap buffered request execution in one absolute deadline and let the embedding-owned future release DNS, connect, response body, permit, SSE, and WebSocket state when retired. +11. Preserve `cancellation.rs` for its remaining non-HTTP production callers. Whole-file deletion requires a separate verified migration of Invocation, IO, SQLite, resources, and generic host-bridge state. + +**Commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream \ + cargo test --locked --test http_host_tests --features http-client +``` + +Expected: existing buffered tests plus shared-policy tests pass. + +The HTTP tests must additionally prove that reset/shutdown/drop retire an active buffered request through the async bridge, release its in-flight permit, ignore late completion, and leave no HTTP-owned abort handle, token, or pending-op entry. + +**Commit:** `refactor(http): share bounded connection policy` + +### Milestone 5: Add callable-driven SSE + +**Objective:** Parse bounded event streams and deliver one normalized event per callback invocation. + +**Files:** +- Create: `src/builtins/runtime/http/sse.rs` +- Modify: `src/builtins/runtime/http/mod.rs` +- Modify: `build.rs` +- Modify: `Cargo.toml` +- Modify: `tests/vm/http_host_tests.rs` +- Create: `tests/vm/http_sse_tests.rs` + +**Implementation:** + +1. Register `http::client::sse` as a feature-gated DefaultHost callable alongside `http::client::request`. Host imports use the registry/profile binding path and do not consume static builtin IDs. +2. Admit only GET/POST requests using `http`/`https`; set `Accept: text/event-stream` when absent and reject a response whose content type is not `text/event-stream`. +3. Reuse shared redirects and connection policy before exposing `open`. +4. Decode chunks incrementally with bounded line and event buffers; split UTF-8 only after full code points are available. +5. Emit `open`, parsed `event` items, and `end` through the generic pump. Do not buffer the whole body. +6. Decode callback maps into only `continue` or `stop`; all other actions are errors. +7. Track wire bytes and delivered item count for the terminal summary. +8. Apply the admission-time absolute deadline continuously while opening and reading, using `min(max_stream_duration, timeout_ms)` when `timeout_ms` is supplied. Apply idle timeout separately while waiting for bytes. Time spent inside the callback is excluded from network idle accounting but remains subject to the embedding invocation deadline. + +**Tests:** + +- events split across arbitrary chunks and UTF-8 boundaries; +- CR/LF variants, BOM, comments, repeated data, id, event, retry, and clean EOF; +- content-type rejection, malformed UTF-8, oversized line/event/total stream; +- callback ordering, callback async wait, callback stop, callback error; +- no producer poll while callback is active; +- redirect revalidation and stripped credentials; +- disconnect before complete event and clean EOF after complete event; +- permit and operation cleanup on every terminal path. + +**Command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream \ + cargo test --locked --test http_sse_tests --features http-client +``` + +**Commit:** `feat(http): add callable-driven SSE client` + +### Milestone 6: Add callable-driven WebSocket + +**Objective:** Support bounded full-duplex WebSocket sessions where callback return maps serialize all outbound actions. + +**Files:** +- Create: `src/builtins/runtime/http/websocket.rs` +- Modify: `src/builtins/runtime/http/mod.rs` +- Modify: `build.rs` +- Modify: `Cargo.toml` +- Modify: `Cargo.lock` +- Create: `tests/vm/http_websocket_tests.rs` + +**Implementation:** + +1. Add optional `tokio-tungstenite`/`tungstenite` dependencies to `http-client`; disable connector features that create a second policy path and select the Rustls integration compatible with current Reqwest TLS. +2. Register `http::client::websocket` as a feature-gated DefaultHost callable. Keep discovery, generated metadata, direct binding, cached binding, and capability-profile admission equivalent. +3. Parse `ws`/`wss`, headers, and optional subprotocols. Reject userinfo and client-managed upgrade headers. +4. Resolve and validate the destination through shared policy, connect to the validated address, and preserve the original hostname for TLS/SNI and HTTP Host. +5. Perform the upgrade with no automatic redirect. If redirect support is retained by the selected handshake path, each redirect must re-enter shared validation and credential stripping before reconnecting. +6. Validate status 101 and selected subprotocol before emitting `open`. +7. Convert incoming protocol messages to one callback item. Reassemble fragmented text/binary messages under limits. +8. Decode callback action maps. Apply exactly one outbound action before polling another inbound item; bound send payload and cumulative bytes. +9. Handle ping/pong explicitly through callback actions. The implementation may send protocol-required close acknowledgments automatically, but must not hide application data messages. +10. Implement the close handshake and configured timeout; return `closed` only after local/peer close semantics are satisfied. +11. Drop the connection on `stop`, callback error, VM lifecycle teardown, deadline, or protocol error without invoking a callback after terminal state. + +**Tests:** + +- handshake, headers, SNI/Host, and subprotocol selection; +- text/binary and fragmented message delivery; +- send_text/send_binary/ping/pong/close action ordering; +- peer close and local close handshake; +- stop, invalid action, invalid close code/reason, oversized frame/message/send/total bytes; +- idle timeout and abnormal EOF; +- host/port/private-address/DNS policy cannot be bypassed; +- callback waits/yields without concurrent socket polling; +- reset/shutdown/drop cleanup and no late callback. + +**Command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream \ + cargo test --locked --test http_websocket_tests --features http-client +``` + +**Commit:** `feat(http): add callable-driven WebSocket client` + +### Milestone 7: Capability, docs, and backend conformance + +**Objective:** Make the two new calls explicit capabilities and document their bounded callable contract. + +**Files:** +- Create: `docs/http-client.md` +- Modify: `docs/callable-runtime.md` +- Modify: `README.md` +- Modify: `tests/host_binding_generation_tests.rs` +- Modify: the existing VM backend parity tests that own host-call suspension + +**Implementation:** + +1. Treat each callable as an independent capability: + - `http::client::request` + - `http::client::sse` + - `http::client::websocket` +2. A profile granting buffered HTTP does not grant either streaming protocol. +3. Document callback schemas, action maps, terminal summaries, limits, and lifecycle behavior. +4. Document that API-level cancellation, handles, detached streams, reconnection, and provider semantics are absent. +5. Document the PR #13 migration: HTTP-private abort handles and the later generic cancellation-token dependency are removed, while embedding-owned future retirement remains part of VM lifecycle. +6. Verify interpreter/JIT/AOT callable-pump parity and keep no-std builds free of HTTP implementations. Existing static builtin IDs remain untouched because these APIs are host imports. + +**Commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream \ + cargo test --locked --test host_binding_generation_tests --all-features +``` + +Keep capability-profile cases in the existing `host_binding_generation_tests` target; do not create a duplicate integration target solely for these cases. + +**Commit:** `docs(http): define streaming callable contract` + +### Milestone 8: Full verification and cleanup + +Run all commands with generated output and target directories under `/mnt/TEMP/rustscript/`: + +```bash +cargo fmt --all -- --check +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream cargo test --locked -p pd-host-function +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream cargo test --locked --test host_binding_generation_tests --all-features +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream cargo test --locked --test host_stream_callback_tests --all-features +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream cargo test --locked --test http_host_tests --features http-client +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream cargo test --locked --test http_sse_tests --features http-client +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream cargo test --locked --test http_websocket_tests --features http-client +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream cargo test --locked --workspace --all-features +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream cargo test --locked --workspace --no-default-features --tests --no-run +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/target-http-stream RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --all-features --no-deps +git diff --check +``` + +Then remove `/mnt/TEMP/rustscript/target-http-stream` and any local fixture output. + +## Rejected designs + +### Script-visible response/socket handles + +Rejected because they require handle ownership, repeated `next_*` calls, close/cancel races, detached-resource policy, and cleanup across arbitrary script control flow. + +### Script-visible `cancel(request_id)` + +Rejected because the stream call already has deterministic terminal paths: callback action, peer EOF/close, timeout, VM lifecycle termination, or error. An extra request-ID namespace adds race states without enabling SSE/WebSocket delivery. + +### Network future directly invoking the callable + +Rejected because it would re-enter a mutable VM from a future that is itself being driven for that VM, allow concurrent interpreter/network progress, and bypass the existing frame/wait/yield state machine. + +### Unbounded callback queue + +Rejected because it disconnects network pressure from script processing and allows a fast peer to consume host memory while the callback is waiting. + +### Provider-specific SSE parsing in core + +Rejected because `[DONE]`, tool-call deltas, retry policy, and provider JSON ownership belong to RSS/downstream code. + +## Target criteria + +- Buffered `http::client::request` remains compatible and bounded. +- RSS can process SSE events and WebSocket messages before connection EOF. +- Streaming uses callable values with an explicit `fn(map) -> map` contract. +- A callback can wait or yield and then resume the outer stream call correctly. +- One-item alternation proves backpressure; no unbounded callback queue exists. +- Scripts receive no HTTP request ID, stream/socket handle, `next_*`, `close`, or cancel callable. +- HTTP owns no pending-operation map, abort-handle map, `CancellationToken`, `OperationOwner::Http`, or owner-wide cancellation route. +- Async bridge retirement drops active HTTP/SSE/WebSocket futures, rejects late completion, and releases permits and transports exactly once. +- SSE and WebSocket terminal summaries are deterministic and delivered once. +- Every network path enforces scheme, host, port, DNS/address, TLS hostname, redirect, header, byte, idle, and duration policy. +- VM reset/shutdown/drop and invocation termination release active streams internally with no late callback. +- Each streaming protocol is independently capability-gated. +- No provider, model, agent-loop, reconnect, or platform policy enters RustScript core. +- All temporary output is removed after verification. diff --git a/src/builtins/metadata.rs b/src/builtins/metadata.rs index b7405f94..7881845f 100644 --- a/src/builtins/metadata.rs +++ b/src/builtins/metadata.rs @@ -10,6 +10,13 @@ pub enum CallableParamType { Array, Map, Number, + Callable(CallableType), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CallableType { + pub params: &'static [CallableParamType], + pub return_type: &'static CallableParamType, } impl CallableParamType { @@ -25,6 +32,23 @@ impl CallableParamType { Self::Array => "array", Self::Map => "map", Self::Number => "number", + Self::Callable(_) => "function", + } + } + + pub fn display_label(self) -> String { + match self { + Self::Callable(signature) => format!( + "fn({}) -> {}", + signature + .params + .iter() + .map(|param| param.display_label()) + .collect::>() + .join(", "), + signature.return_type.display_label() + ), + other => other.label().to_string(), } } } diff --git a/src/builtins/mod.rs b/src/builtins/mod.rs index b47f7405..2d5fd2ca 100644 --- a/src/builtins/mod.rs +++ b/src/builtins/mod.rs @@ -6,7 +6,7 @@ mod metadata; pub(crate) mod runtime; pub use self::metadata::{ - CallableDef, CallableParam, CallableParamType, CallableSignature, HostExecution, + CallableDef, CallableParam, CallableParamType, CallableSignature, CallableType, HostExecution, }; use crate::ValueType; #[cfg(feature = "runtime")] diff --git a/src/builtins/runtime/cancellation.rs b/src/builtins/runtime/cancellation.rs index 85b53c16..76f26f2e 100644 --- a/src/builtins/runtime/cancellation.rs +++ b/src/builtins/runtime/cancellation.rs @@ -35,7 +35,6 @@ impl OperationId { pub enum OperationOwner { HostBridge, Io, - Http, #[cfg(feature = "sqlite")] Sqlite, } @@ -689,6 +688,7 @@ impl OperationRegistry { .ok_or_else(|| operation_not_found(id)) } + #[cfg(feature = "sqlite")] pub fn operations_by_owner(&self, owner: OperationOwner) -> Vec { let operations = self.registered_operations(); operations @@ -815,10 +815,10 @@ mod tests { fn parent_cancellation_propagates_and_deadline_is_structured() { let mut registry = OperationRegistry::with_limit(4).expect("registry should be valid"); let parent = registry - .start_owned(OperationOwner::Http, None, None, None) + .start_owned(OperationOwner::Io, None, None, None) .expect("parent should start"); let child = registry - .start_owned(OperationOwner::Http, Some(&parent.token()), None, None) + .start_owned(OperationOwner::Io, Some(&parent.token()), None, None) .expect("child should start"); assert!( parent @@ -845,7 +845,7 @@ mod tests { fn cancel_all_counts_children_cancelled_by_parent_propagation() { let mut registry = OperationRegistry::with_limit(2).expect("registry should be valid"); let parent = registry - .start_owned(OperationOwner::Http, None, None, None) + .start_owned(OperationOwner::Io, None, None, None) .expect("parent should start"); let child = registry .start_owned(OperationOwner::Io, Some(&parent.token()), None, None) @@ -874,7 +874,7 @@ mod tests { let cleanup_count = Arc::clone(&child_cleanup_count); let mut registry = OperationRegistry::with_limit(2).expect("registry should be valid"); let parent = registry - .start_owned(OperationOwner::Http, None, None, None) + .start_owned(OperationOwner::Io, None, None, None) .expect("parent should start"); let child = registry .start_owned( @@ -920,7 +920,7 @@ mod tests { fn parent_cancellation_propagates_child_cleanup_failure() { let mut registry = OperationRegistry::with_limit(2).expect("registry should be valid"); let parent = registry - .start_owned(OperationOwner::Http, None, None, None) + .start_owned(OperationOwner::Io, None, None, None) .expect("parent should start"); registry .start_owned( @@ -985,7 +985,7 @@ mod tests { fn attaching_children_prunes_completed_parent_links() { let mut registry = OperationRegistry::with_limit(2).expect("registry should be valid"); let parent = registry - .start_owned(OperationOwner::Http, None, None, None) + .start_owned(OperationOwner::Io, None, None, None) .expect("parent should start"); for _ in 0..32 { diff --git a/src/builtins/runtime/http.rs b/src/builtins/runtime/http.rs deleted file mode 100644 index bdb4b3fb..00000000 --- a/src/builtins/runtime/http.rs +++ /dev/null @@ -1,866 +0,0 @@ -#[cfg(feature = "async")] -use futures_util::StreamExt; -use std::sync::Arc; -use std::sync::atomic::AtomicUsize; -#[cfg(feature = "async")] -use std::sync::atomic::Ordering; - -#[cfg(feature = "async")] -use pd_host_function::pd_host_function; - -#[cfg(feature = "async")] -use super::{VmMap, VmResult}; -#[cfg(feature = "async")] -use crate::builtins::runtime::cancellation::{CancellationReason, CancellationToken}; -#[cfg(feature = "async")] -use crate::vm::CaptureAsyncHostContext; -#[cfg(feature = "async")] -use crate::vm::Value; -use crate::vm::Vm; -#[cfg(feature = "async")] -use crate::vm::VmError; - -#[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, - pub max_response_body_bytes: usize, - pub connect_timeout: std::time::Duration, - pub request_timeout: std::time::Duration, - pub allow_private_ips: bool, -} - -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_response_body_bytes: 8 * 1024 * 1024, - connect_timeout: std::time::Duration::from_secs(10), - request_timeout: std::time::Duration::from_secs(30), - allow_private_ips: false, - } - } -} - -#[derive(Default)] -struct HttpHostState { - #[cfg(feature = "async")] - config: Option, - max_in_flight: usize, - in_flight: Arc, -} - -/// HTTP host configuration owned by the HTTP host implementation. -pub trait HttpHostExt { - fn configure_http(&mut self, config: HttpConfig); - fn set_http_max_in_flight(&mut self, max_in_flight: usize); - fn http_max_in_flight(&self) -> usize; - fn clear_http_configuration(&mut self); - fn http_is_configured(&self) -> bool; -} - -impl HttpHostExt for Vm { - fn configure_http(&mut self, config: HttpConfig) { - let (max_in_flight, in_flight) = self - .host - .host_function_state::() - .map_or_else( - || { - ( - crate::builtins::runtime::cancellation::DEFAULT_MAX_PENDING_OPERATIONS, - Arc::new(AtomicUsize::new(0)), - ) - }, - |state| (state.max_in_flight, Arc::clone(&state.in_flight)), - ); - self.host.set_host_function_state(HttpHostState { - #[cfg(feature = "async")] - config: Some(config), - max_in_flight, - in_flight, - }); - #[cfg(not(feature = "async"))] - let _ = config; - } - - fn set_http_max_in_flight(&mut self, max_in_flight: usize) { - if self.host.host_function_state::().is_none() { - self.host.set_host_function_state(HttpHostState { - #[cfg(feature = "async")] - config: None, - max_in_flight: - crate::builtins::runtime::cancellation::DEFAULT_MAX_PENDING_OPERATIONS, - in_flight: Arc::new(AtomicUsize::new(0)), - }); - } - self.host - .host_function_state_mut::() - .expect("HTTP host state was inserted") - .max_in_flight = max_in_flight; - } - - fn http_max_in_flight(&self) -> usize { - self.host.host_function_state::().map_or( - crate::builtins::runtime::cancellation::DEFAULT_MAX_PENDING_OPERATIONS, - |state| state.max_in_flight, - ) - } - - fn clear_http_configuration(&mut self) { - crate::builtins::runtime::cancel_operations_by_owner( - self, - crate::builtins::runtime::cancellation::OperationOwner::Http, - crate::builtins::runtime::cancellation::CancellationReason::Requested, - ); - self.host.remove_host_function_state::(); - } - - fn http_is_configured(&self) -> bool { - #[cfg(feature = "async")] - { - self.host - .host_function_state::() - .and_then(|state| state.config.as_ref()) - .is_some() - } - #[cfg(not(feature = "async"))] - false - } -} - -#[cfg(feature = "async")] -fn runtime_host_error(error: impl std::fmt::Display) -> VmError { - VmError::HostError(error.to_string()) -} - -#[cfg(feature = "async")] -fn cancellation_vm_error(token: &CancellationToken) -> VmError { - token - .check() - .map(|()| VmError::HostError("HTTP request was cancelled".to_string())) - .unwrap_or_else(runtime_host_error) -} - -#[cfg(feature = "async")] -pub(super) struct HttpRequestContext { - config: HttpConfig, - cancellation: CancellationToken, - _permit: HttpInFlightPermit, -} - -#[cfg(feature = "async")] -struct HttpInFlightPermit { - active: Arc, -} - -#[cfg(feature = "async")] -impl HttpInFlightPermit { - fn acquire(state: &HttpHostState) -> VmResult { - let mut active = state.in_flight.load(Ordering::Acquire); - loop { - if active >= state.max_in_flight { - return Err(VmError::HostError(format!( - "HTTP in-flight request limit of {} was reached", - state.max_in_flight - ))); - } - match state.in_flight.compare_exchange_weak( - active, - active + 1, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => { - return Ok(Self { - active: Arc::clone(&state.in_flight), - }); - } - Err(observed) => active = observed, - } - } - } -} - -#[cfg(feature = "async")] -impl Drop for HttpInFlightPermit { - fn drop(&mut self) { - self.active.fetch_sub(1, Ordering::AcqRel); - } -} - -#[cfg(feature = "async")] -impl CaptureAsyncHostContext for HttpRequestContext { - fn capture(vm: &mut Vm) -> VmResult { - let state = vm - .host - .host_function_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 permit = HttpInFlightPermit::acquire(state)?; - Ok(Self { - config, - cancellation: CancellationToken::root(), - _permit: permit, - }) - } -} - -/// 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`. -#[cfg(feature = "async")] -#[pd_host_function(name = "http::client::request")] -pub(super) async fn builtin_http_client_request( - #[pd_host_context] context: HttpRequestContext, - request: VmMap, -) -> VmResult { - let request = parse_request(&request, &context.config)?; - let deadline = std::time::Instant::now() + context.config.request_timeout; - execute_request(&context.config, &request, &context.cancellation, deadline).await -} - -#[cfg(feature = "async")] -struct HttpRequest { - method: reqwest::Method, - url: url::Url, - headers: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>, - body: Option>, -} - -#[cfg(feature = "async")] -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 = reqwest::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}")))?; - if !url.username().is_empty() || url.password().is_some() { - return Err(VmError::HostError( - "HTTP URL userinfo is not allowed".to_string(), - )); - } - 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(); - 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")); - }; - 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 = reqwest::header::HeaderName::from_bytes(key.as_bytes()) - .map_err(|_| VmError::HostError(format!("invalid HTTP header name '{key}'")))?; - let value = reqwest::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")); - } - - Ok(HttpRequest { - method, - url, - headers, - body, - }) -} - -#[cfg(feature = "async")] -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}'" - ))), - } -} - -#[cfg(feature = "async")] -fn validate_url_policy<'a>(config: &HttpConfig, url: &'a url::Url) -> VmResult<(&'a str, u16)> { - let scheme = url.scheme().to_ascii_lowercase(); - if !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() - .ok_or_else(|| VmError::HostError("HTTP URL has no host".to_string()))?; - 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, port)) -} - -#[cfg(all(feature = "async", test))] -fn validate_url(config: &HttpConfig, url: &url::Url) -> VmResult> { - let (host, port) = validate_url_policy(config, url)?; - if config.allow_private_ips { - return Ok(None); - } - - if let Ok(host_ip) = host.parse::() { - validate_resolved_addresses(config, &[std::net::SocketAddr::new(host_ip, port)])?; - return Ok(None); - } - - use std::net::ToSocketAddrs; - let addresses = (host, 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(feature = "async")] -async fn resolve_url( - config: &HttpConfig, - url: &url::Url, - token: &CancellationToken, - deadline: std::time::Instant, -) -> VmResult> { - token.check().map_err(runtime_host_error)?; - let (host, port) = validate_url_policy(config, url)?; - if let Ok(host_ip) = host.parse::() { - let address = std::net::SocketAddr::new(host_ip, port); - validate_resolved_addresses(config, &[address])?; - return Ok(Some(address)); - } - - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - token.cancel(CancellationReason::Deadline); - return Err(cancellation_vm_error(token)); - } - let addresses = tokio::time::timeout(remaining, tokio::net::lookup_host((host, port))) - .await - .map_err(|_| { - token.cancel(CancellationReason::Deadline); - cancellation_vm_error(token) - })? - .map_err(|error| VmError::HostError(format!("HTTP host resolution failed: {error}")))? - .collect::>(); - token.check().map_err(runtime_host_error)?; - validate_resolved_addresses(config, &addresses)?; - addresses - .first() - .copied() - .map(Some) - .ok_or_else(|| VmError::HostError("HTTP target resolves to a restricted IP".to_string())) -} - -#[cfg(feature = "async")] -fn validate_resolved_addresses( - config: &HttpConfig, - addresses: &[std::net::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(()) -} - -#[cfg(feature = "async")] -fn is_restricted_ip(ip: std::net::IpAddr) -> bool { - match ip { - std::net::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 - } - std::net::IpAddr::V6(ip) => { - if let Some(mapped) = ip.to_ipv4_mapped() { - return is_restricted_ip(std::net::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 - } - } -} - -#[cfg(feature = "async")] -async fn execute_request( - config: &HttpConfig, - request: &HttpRequest, - token: &CancellationToken, - deadline: std::time::Instant, -) -> VmResult { - token.check().map_err(runtime_host_error)?; - 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 { - token.check().map_err(runtime_host_error)?; - let resolved_address = resolve_url(config, &url, token, deadline).await?; - let host = url - .host_str() - .ok_or_else(|| VmError::HostError("HTTP URL has no host".to_string()))?; - let mut client_builder = reqwest::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .connect_timeout(config.connect_timeout); - if let Some(address) = resolved_address { - client_builder = client_builder.resolve(host, address); - } - let client = client_builder - .build() - .map_err(|error| VmError::HostError(format!("HTTP client setup failed: {error}")))?; - let origin = request.url.origin(); - let mut builder = client.request(method.clone(), url.clone()); - for (name, value) in &headers { - builder = builder.header(name, value); - } - if let Some(body) = &body { - builder = builder.body(body.clone()); - } - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - token.cancel(CancellationReason::Deadline); - return Err(cancellation_vm_error(token)); - } - let response = tokio::time::timeout(remaining, builder.send()) - .await - .map_err(|_| { - token.cancel(CancellationReason::Deadline); - cancellation_vm_error(token) - })? - .map_err(|error| { - if error.is_timeout() { - token.cancel(CancellationReason::Deadline); - cancellation_vm_error(token) - } else { - VmError::HostError(format!("HTTP request failed: {error}")) - } - })?; - token.check().map_err(runtime_host_error)?; - if response.status().is_redirection() { - if redirect_index == config.max_redirects { - return Err(VmError::HostError( - "HTTP redirect limit exceeded".to_string(), - )); - } - let location = response - .headers() - .get(reqwest::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}")))?; - if next_url.origin() != origin { - headers.retain(|(name, _)| { - name != reqwest::header::AUTHORIZATION && name != reqwest::header::COOKIE - }); - } - if response.status() == reqwest::StatusCode::SEE_OTHER - || ((response.status() == reqwest::StatusCode::MOVED_PERMANENTLY - || response.status() == reqwest::StatusCode::FOUND) - && method != reqwest::Method::GET - && method != reqwest::Method::HEAD) - { - method = reqwest::Method::GET; - body = None; - } - url = next_url; - continue; - } - - let response_headers = response - .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::>(); - let status = response.status(); - let mut bytes = Vec::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - token.cancel(CancellationReason::Deadline); - return Err(cancellation_vm_error(token)); - } - tokio::time::timeout(remaining, stream.next()) - .await - .map_err(|_| { - token.cancel(CancellationReason::Deadline); - cancellation_vm_error(token) - })? - } { - token.check().map_err(runtime_host_error)?; - let chunk = chunk.map_err(|error| { - VmError::HostError(format!("HTTP response read failed: {error}")) - })?; - if bytes.len().saturating_add(chunk.len()) > config.max_response_body_bytes { - return Err(VmError::HostError( - "HTTP response body exceeds limit".to_string(), - )); - } - bytes.extend_from_slice(&chunk); - } - let response_map = 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(response_headers))), - ), - (Value::string("body"), Value::bytes(bytes)), - (Value::string("url"), Value::string(url.as_str())), - ]); - return Ok(response_map); - } - - Err(VmError::HostError( - "HTTP redirect processing failed".to_string(), - )) -} - -#[cfg(test)] -mod tests { - use super::HttpConfig; - #[cfg(feature = "async")] - use super::HttpHostExt; - #[cfg(feature = "async")] - use super::{ - CancellationReason, HttpRequest, VmMap, builtin_http_client_request, execute_request, - is_restricted_ip, validate_resolved_addresses, validate_url, - }; - #[cfg(feature = "async")] - use crate::vm::{ - CallOutcome, CallReturn, HostAsyncBridge, HostFuture, HostOpId, Value, VmResult, - }; - - #[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); - } - - #[cfg(feature = "async")] - #[test] - fn request_submits_future_to_host_driver_without_runtime_operation() { - use std::sync::{Arc, Mutex}; - use std::task::{Context, Poll}; - - struct RecordingBridge { - submitted: Arc>>, - } - - impl HostAsyncBridge for RecordingBridge { - fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { - *self.submitted.lock().expect("submission lock") = Some((op_id, future)); - Ok(()) - } - - fn poll_op( - &mut self, - _op_id: HostOpId, - _cx: &mut Context<'_>, - ) -> Poll> { - Poll::Pending - } - } - - let submitted = Arc::new(Mutex::new(None)); - let mut vm = crate::vm::Vm::new(crate::vm::Program::new(Vec::new(), Vec::new())); - vm.configure_http(HttpConfig::default()); - vm.set_async_bridge(Box::new(RecordingBridge { - submitted: Arc::clone(&submitted), - })); - let args = [Value::Map(Arc::new(VmMap::default()))]; - - let outcome = builtin_http_client_request(&mut vm, &args) - .expect("HTTP async host call should submit"); - let CallOutcome::Pending(op_id) = outcome else { - panic!("HTTP async host call should suspend"); - }; - assert_eq!(op_id, 1); - assert_eq!( - submitted - .lock() - .expect("submission lock") - .as_ref() - .map(|(submitted_id, _)| *submitted_id), - Some(op_id) - ); - assert_eq!(vm.host.runtime_operations.active_count(), 0); - } - - #[cfg(feature = "async")] - #[test] - fn production_request_timeout_sets_structured_deadline_reason() { - use std::time::{Duration, Instant}; - - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); - let address = listener.local_addr().expect("listener should have address"); - let server = std::thread::spawn(move || { - let (_socket, _) = listener.accept().expect("request should connect"); - std::thread::sleep(Duration::from_millis(100)); - }); - let config = HttpConfig { - allowed_schemes: vec!["http".to_string()], - allowed_hosts: vec!["127.0.0.1".to_string()], - allowed_ports: vec![address.port()], - allow_private_ips: true, - connect_timeout: Duration::from_millis(50), - request_timeout: Duration::from_millis(20), - ..HttpConfig::default() - }; - let request = HttpRequest { - method: reqwest::Method::GET, - url: format!("http://{address}/").parse().expect("valid URL"), - headers: Vec::new(), - body: None, - }; - let token = crate::builtins::runtime::cancellation::CancellationToken::root(); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime should build"); - - runtime - .block_on(execute_request( - &config, - &request, - &token, - Instant::now() + config.request_timeout, - )) - .expect_err("hanging server should time out"); - assert_eq!(token.reason(), Some(CancellationReason::Deadline)); - server.join().expect("server should exit"); - } - - #[cfg(feature = "async")] - #[test] - fn response_body_timeout_sets_structured_deadline_reason() { - use std::io::{Read, Write}; - use std::time::{Duration, Instant}; - - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); - let address = listener.local_addr().expect("listener should have address"); - let server = std::thread::spawn(move || { - let (mut socket, _) = listener.accept().expect("request should connect"); - let mut request = [0u8; 1024]; - let _ = socket - .read(&mut request) - .expect("request should be readable"); - socket - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\n") - .expect("headers should be written"); - socket.flush().expect("headers should flush"); - std::thread::sleep(Duration::from_millis(100)); - }); - let config = HttpConfig { - allowed_schemes: vec!["http".to_string()], - allowed_hosts: vec!["127.0.0.1".to_string()], - allowed_ports: vec![address.port()], - allow_private_ips: true, - connect_timeout: Duration::from_millis(50), - request_timeout: Duration::from_millis(20), - ..HttpConfig::default() - }; - let request = HttpRequest { - method: reqwest::Method::GET, - url: format!("http://{address}/").parse().expect("valid URL"), - headers: Vec::new(), - body: None, - }; - let token = crate::builtins::runtime::cancellation::CancellationToken::root(); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("runtime should build"); - - runtime - .block_on(execute_request( - &config, - &request, - &token, - Instant::now() + config.request_timeout, - )) - .expect_err("stalled response body should time out"); - assert_eq!(token.reason(), Some(CancellationReason::Deadline)); - server.join().expect("server should exit"); - } - - #[cfg(feature = "async")] - #[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, &explicit).is_err()); - assert!(validate_url(&config, &default_port).is_err()); - } - - #[cfg(feature = "async")] - #[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()); - } - - #[cfg(feature = "async")] - #[test] - fn ipv4_mapped_ipv6_loopback_is_restricted() { - assert!(is_restricted_ip( - "::ffff:127.0.0.1".parse().expect("valid IP") - )); - } -} diff --git a/src/builtins/runtime/http/config.rs b/src/builtins/runtime/http/config.rs new file mode 100644 index 00000000..7351d99f --- /dev/null +++ b/src/builtins/runtime/http/config.rs @@ -0,0 +1,91 @@ +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, + 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_websocket_frame_bytes: usize, + pub max_websocket_send_bytes: usize, + pub max_stream_duration: Duration, + pub stream_idle_timeout: Duration, + pub websocket_close_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), + ("max_websocket_frame_bytes", self.max_websocket_frame_bytes), + ("max_websocket_send_bytes", self.max_websocket_send_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_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), + ("websocket_close_timeout", self.websocket_close_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(), "wss".to_string()], + allowed_hosts: Vec::new(), + allowed_ports: Vec::new(), + max_redirects: 5, + max_request_body_bytes: 1024 * 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_websocket_frame_bytes: 1024 * 1024, + max_websocket_send_bytes: 1024 * 1024, + max_stream_duration: Duration::from_secs(5 * 60), + stream_idle_timeout: Duration::from_secs(30), + websocket_close_timeout: Duration::from_secs(5), + } + } +} diff --git a/src/builtins/runtime/http/mod.rs b/src/builtins/runtime/http/mod.rs new file mode 100644 index 00000000..52dd4ef7 --- /dev/null +++ b/src/builtins/runtime/http/mod.rs @@ -0,0 +1,1186 @@ +use std::time::{Duration, Instant}; + +use pd_host_function::pd_host_function; + +use super::{borrow_arg, take_arg}; +use crate::builtins::runtime::VmMap; +use crate::vm::{CaptureAsyncHostContext, Vm, VmError, VmResult}; + +mod config; +pub(super) mod policy; +pub(super) mod request; +pub(super) mod sse; +pub(super) mod websocket; + +pub use config::HttpConfig; +use policy::{ConnectionAdmission, ConnectionPermit}; + +const DEFAULT_MAX_HTTP_IN_FLIGHT: usize = 64; + +struct HttpHostState { + config: Option, + admission: ConnectionAdmission, +} + +/// HTTP host configuration owned by the HTTP host implementation. +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(&self) -> usize; + fn clear_http_configuration(&mut self); + fn http_is_configured(&self) -> bool; +} + +impl HttpHostExt for Vm { + fn configure_http(&mut self, config: HttpConfig) -> VmResult<()> { + config.validate()?; + let admission = self + .host + .host_function_state::() + .map_or_else( + || ConnectionAdmission::new(DEFAULT_MAX_HTTP_IN_FLIGHT), + |state| state.admission.clone(), + ); + self.host.set_host_function_state(HttpHostState { + config: Some(config), + admission, + }); + Ok(()) + } + + fn set_http_max_in_flight(&mut self, max_in_flight: usize) { + if self.host.host_function_state::().is_none() { + self.host.set_host_function_state(HttpHostState { + config: None, + admission: ConnectionAdmission::new(DEFAULT_MAX_HTTP_IN_FLIGHT), + }); + } + self.host + .host_function_state_mut::() + .expect("HTTP host state was inserted") + .admission + .set_max_in_flight(max_in_flight); + } + + fn http_max_in_flight(&self) -> usize { + self.host + .host_function_state::() + .map_or(DEFAULT_MAX_HTTP_IN_FLIGHT, |state| { + state.admission.max_in_flight() + }) + } + + fn clear_http_configuration(&mut self) { + if let Some(state) = self.host.host_function_state_mut::() { + state.config = None; + } + } + + fn http_is_configured(&self) -> bool { + self.host + .host_function_state::() + .and_then(|state| state.config.as_ref()) + .is_some() + } +} + +pub(super) struct HttpRequestContext { + config: HttpConfig, + _permit: ConnectionPermit, +} + +impl HttpRequestContext { + fn capture_stream( + vm: &mut Vm, + script_timeout: Option, + protocol: &str, + ) -> VmResult<(Self, Instant)> { + let state = vm + .host + .host_function_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: permit, + }, + deadline, + )) + } + + fn into_parts(self) -> (HttpConfig, ConnectionPermit) { + (self.config, self._permit) + } +} + +impl CaptureAsyncHostContext for HttpRequestContext { + fn capture(vm: &mut Vm) -> VmResult { + let state = vm + .host + .host_function_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 permit = state.admission.acquire()?; + Ok(Self { + config, + _permit: permit, + }) + } +} + +/// 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) async fn builtin_http_client_request( + #[pd_host_context] context: HttpRequestContext, + request: VmMap, +) -> VmResult { + request::perform_buffered_request(context, request).await +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + + use super::policy::{ + SchemeFamily, is_restricted_ip, request_deadline, validate_resolved_addresses, + validate_url, validate_url_policy, + }; + use super::request::{ + HttpRequest, ResponseReadObserver, execute_request, execute_request_with_observer, + execute_request_with_tls_config, pending_connection_test, + }; + use super::{HttpConfig, HttpHostExt, HttpRequestContext, builtin_http_client_request}; + use crate::builtins::runtime::VmMap; + use crate::vm::{ + CallOutcome, CallReturn, HostAsyncBridge, HostFuture, HostOpId, Value, VmResult, + }; + + #[test] + fn default_http_policy_denies_all_hosts() { + let config = HttpConfig::default(); + assert_eq!(config.allowed_schemes, ["https", "wss"]); + 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 = HttpRequestContext::capture_stream(&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 scheme_families_are_protocol_specific() { + let config = HttpConfig { + allowed_schemes: vec!["http".into(), "https".into(), "ws".into(), "wss".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 ws: url::Url = "wss://example.com/".parse().expect("valid URL"); + assert!(validate_url_policy(&config, SchemeFamily::Http, &http).is_ok()); + assert!(validate_url_policy(&config, SchemeFamily::WebSocket, &ws).is_ok()); + assert!(validate_url_policy(&config, SchemeFamily::Http, &ws).is_err()); + assert!(validate_url_policy(&config, SchemeFamily::WebSocket, &http).is_err()); + } + + #[test] + fn request_submits_future_to_host_driver_without_runtime_operation() { + use std::task::{Context, Poll}; + + struct RecordingBridge { + submitted: Arc>>, + } + + impl HostAsyncBridge for RecordingBridge { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + *self.submitted.lock().expect("submission lock") = Some((op_id, future)); + Ok(()) + } + + fn poll_op( + &mut self, + _op_id: HostOpId, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } + } + + let submitted = Arc::new(Mutex::new(None)); + 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"); + vm.set_async_bridge(Box::new(RecordingBridge { + submitted: Arc::clone(&submitted), + })); + let args = [Value::Map(Arc::new(VmMap::default()))]; + + let outcome = builtin_http_client_request(&mut vm, &args) + .expect("HTTP async host call should submit"); + let CallOutcome::Pending(op_id) = outcome else { + panic!("HTTP async host call should suspend"); + }; + assert_eq!(op_id, 1); + assert_eq!( + submitted + .lock() + .expect("submission lock") + .as_ref() + .map(|(submitted_id, _)| *submitted_id), + Some(op_id) + ); + assert_eq!(vm.host.runtime_operations.active_count(), 0); + } + + #[test] + fn production_request_timeout_covers_delayed_headers() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let server = std::thread::spawn(move || { + let (_socket, _) = listener.accept().expect("request should connect"); + std::thread::sleep(Duration::from_millis(100)); + }); + let config = HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![address.port()], + allow_private_ips: true, + connect_timeout: Duration::from_millis(50), + request_timeout: Duration::from_millis(20), + ..HttpConfig::default() + }; + let request = HttpRequest { + method: hyper::Method::GET, + url: format!("http://{address}/").parse().expect("valid URL"), + headers: Vec::new(), + body: None, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + + let error = runtime + .block_on(super::policy::with_deadline( + request_deadline(config.request_timeout).expect("valid request deadline"), + execute_request(&config, &request), + )) + .expect_err("hanging server should time out"); + assert!(error.to_string().contains("deadline exceeded")); + server.join().expect("server should exit"); + } + + #[test] + fn response_body_timeout_uses_the_same_total_deadline() { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().expect("request should connect"); + let mut request = [0u8; 1024]; + let _ = socket + .read(&mut request) + .expect("request should be readable"); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\n") + .expect("headers should be written"); + socket.flush().expect("headers should flush"); + std::thread::sleep(Duration::from_millis(100)); + }); + let config = HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![address.port()], + allow_private_ips: true, + connect_timeout: Duration::from_millis(50), + request_timeout: Duration::from_millis(20), + ..HttpConfig::default() + }; + let request = HttpRequest { + method: hyper::Method::GET, + url: format!("http://{address}/").parse().expect("valid URL"), + headers: Vec::new(), + body: None, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + + let error = runtime + .block_on(super::policy::with_deadline( + request_deadline(config.request_timeout).expect("valid request deadline"), + execute_request(&config, &request), + )) + .expect_err("stalled response body should time out"); + assert!(error.to_string().contains("deadline exceeded")); + server.join().expect("server should exit"); + } + + #[test] + fn redirects_revalidate_policy_and_strip_cross_origin_credentials() { + use std::io::{Read, Write}; + + let first = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let first_address = first.local_addr().expect("listener should have address"); + let second = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let second_address = second.local_addr().expect("listener should have address"); + let first_server = std::thread::spawn(move || { + let (mut socket, _) = first.accept().expect("request should connect"); + let mut bytes = [0_u8; 2048]; + let read = socket.read(&mut bytes).expect("request should be readable"); + let request = String::from_utf8_lossy(&bytes[..read]).to_ascii_lowercase(); + assert!(request.contains("authorization: bearer secret")); + assert!(request.contains("cookie: session=secret")); + write!( + socket, + "HTTP/1.1 302 Found\r\nLocation: http://{second_address}/final\r\nContent-Length: 0\r\n\r\n" + ) + .expect("redirect should be writable"); + }); + let second_server = std::thread::spawn(move || { + let (mut socket, _) = second.accept().expect("request should connect"); + let mut bytes = [0_u8; 2048]; + let read = socket.read(&mut bytes).expect("request should be readable"); + let request = String::from_utf8_lossy(&bytes[..read]).to_ascii_lowercase(); + assert!(!request.contains("authorization:")); + assert!(!request.contains("cookie:")); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .expect("response should be writable"); + }); + let config = HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![first_address.port(), second_address.port()], + allow_private_ips: true, + ..HttpConfig::default() + }; + let request = HttpRequest { + method: hyper::Method::GET, + url: format!("http://{first_address}/") + .parse() + .expect("valid URL"), + headers: vec![ + ( + hyper::header::AUTHORIZATION, + hyper::header::HeaderValue::from_static("Bearer secret"), + ), + ( + hyper::header::COOKIE, + hyper::header::HeaderValue::from_static("session=secret"), + ), + ], + body: None, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + let response = runtime + .block_on(super::policy::with_deadline( + request_deadline(config.request_timeout).expect("valid request deadline"), + execute_request(&config, &request), + )) + .expect("redirected request should complete"); + assert_eq!( + response.get(&Value::string("status")), + Some(&Value::Int(200)) + ); + assert_eq!( + response.get(&Value::string("url")), + Some(&Value::string(format!("http://{second_address}/final"))) + ); + first_server.join().expect("first server should exit"); + second_server.join().expect("second server should exit"); + } + + #[test] + fn redirect_destination_is_revalidated_before_connection() { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().expect("request should connect"); + let mut request = [0_u8; 1024]; + let _ = socket + .read(&mut request) + .expect("request should be readable"); + write!( + socket, + "HTTP/1.1 302 Found\r\nLocation: http://localhost:{}/blocked\r\nContent-Length: 0\r\n\r\n", + address.port() + ) + .expect("redirect should be writable"); + }); + let config = HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![address.port()], + allow_private_ips: true, + ..HttpConfig::default() + }; + let request = HttpRequest { + method: hyper::Method::GET, + url: format!("http://{address}/").parse().expect("valid URL"), + headers: Vec::new(), + body: None, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + let error = runtime + .block_on(super::policy::with_deadline( + request_deadline(config.request_timeout).expect("valid request deadline"), + execute_request(&config, &request), + )) + .expect_err("redirect target should be denied"); + assert!(error.to_string().contains("target host is not allowed")); + server.join().expect("server should exit"); + } + + fn assert_redirect_userinfo_is_rejected(userinfo: &str) { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let userinfo = userinfo.to_string(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().expect("first request should connect"); + let mut request = [0_u8; 2048]; + let read = socket + .read(&mut request) + .expect("request should be readable"); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + assert!(!request.contains("authorization:")); + assert!(!request.contains(&userinfo.to_ascii_lowercase())); + write!( + socket, + "HTTP/1.1 302 Found\r\nLocation: http://{userinfo}@{address}/blocked\r\nContent-Length: 0\r\n\r\n" + ) + .expect("redirect should be writable"); + drop(socket); + + listener + .set_nonblocking(true) + .expect("listener should become nonblocking"); + let deadline = Instant::now() + Duration::from_millis(200); + loop { + match listener.accept() { + Ok(_) => panic!("redirect userinfo must be rejected before a second request"), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("unexpected accept error: {error}"), + } + } + }); + let config = HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![address.port()], + allow_private_ips: true, + ..HttpConfig::default() + }; + let request = HttpRequest { + method: hyper::Method::GET, + url: format!("http://{address}/").parse().expect("valid URL"), + headers: Vec::new(), + body: None, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + + let error = runtime + .block_on(super::policy::with_deadline( + request_deadline(config.request_timeout).expect("valid request deadline"), + execute_request(&config, &request), + )) + .expect_err("redirect userinfo should be denied"); + assert!(error.to_string().contains("URL userinfo is not allowed")); + server.join().expect("server should exit"); + } + + #[test] + fn redirect_username_is_rejected_before_a_second_request() { + assert_redirect_userinfo_is_rejected("redirect-user"); + } + + #[test] + fn redirect_username_and_password_are_rejected_before_a_second_request() { + assert_redirect_userinfo_is_rejected("redirect-user:redirect-password"); + } + + fn execute_fixture_response_fragments_for( + method: hyper::Method, + response: Vec<&'static [u8]>, + max_response_body_bytes: usize, + ) -> (VmResult, ResponseReadObserver) { + use std::io::{Read, Write}; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have address"); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().expect("request should connect"); + let mut request = [0_u8; 2048]; + let _ = socket + .read(&mut request) + .expect("request should be readable"); + for fragment in response { + socket + .write_all(fragment) + .expect("response fragment should be writable"); + socket.flush().expect("response fragment should flush"); + } + }); + let config = HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![address.port()], + allow_private_ips: true, + max_response_body_bytes, + ..HttpConfig::default() + }; + let request = HttpRequest { + method, + url: format!("http://{address}/").parse().expect("valid URL"), + headers: Vec::new(), + body: None, + }; + let observer = ResponseReadObserver::default(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + let result = runtime.block_on(async { + tokio::time::timeout( + Duration::from_millis(500), + execute_request_with_observer(&config, &request, observer.clone()), + ) + .await + .expect("fixture response must make progress without deadline fallback") + }); + server.join().expect("server should exit"); + (result, observer) + } + + fn execute_fixture_response_fragments( + response: Vec<&'static [u8]>, + max_response_body_bytes: usize, + ) -> (VmResult, ResponseReadObserver) { + execute_fixture_response_fragments_for( + hyper::Method::GET, + response, + max_response_body_bytes, + ) + } + + fn execute_fixture_response( + response: &'static [u8], + max_response_body_bytes: usize, + ) -> (VmResult, ResponseReadObserver) { + execute_fixture_response_fragments(vec![response], max_response_body_bytes) + } + + #[test] + fn continue_then_final_response_in_one_write_reaches_the_final_head() { + let (result, observer) = execute_fixture_response( + b"HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + 8, + ); + let response = result.expect("final response should complete after 100 Continue"); + assert_eq!( + response.get(&Value::string("status")), + Some(&Value::Int(200)) + ); + assert_eq!( + response.get(&Value::string("body")), + Some(&Value::bytes(b"ok".to_vec())) + ); + assert!(observer.body_read_calls() > 0); + } + + #[test] + fn fragmented_continue_then_final_response_reaches_the_final_head() { + let (result, _) = execute_fixture_response_fragments( + vec![ + b"HTTP/1.1 100 Cont", + b"inue\r\n", + b"X-Info: yes\r\n\r", + b"\nHTTP/1.1 200 O", + b"K\r\nContent-Length: 2\r\n\r\n", + b"ok", + ], + 8, + ); + let response = result.expect("fragmented final response should complete after 100"); + assert_eq!( + response.get(&Value::string("body")), + Some(&Value::bytes(b"ok".to_vec())) + ); + } + + #[test] + fn early_hints_then_final_response_in_one_write_reaches_the_final_head() { + let (result, _) = execute_fixture_response( + b"HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\nHTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok", + 8, + ); + let response = result.expect("final response should complete after 103 Early Hints"); + assert_eq!( + response.get(&Value::string("status")), + Some(&Value::Int(200)) + ); + } + + #[test] + fn fragmented_early_hints_then_final_response_reaches_the_final_head() { + let (result, _) = execute_fixture_response_fragments( + vec![ + b"HTTP/1.1 103 Early Hints\r\n", + b"Link: \r\n\r\nHTTP/1.1 ", + b"200 OK\r\nContent-Length: 2\r\n", + b"\r\nok", + ], + 8, + ); + let response = result.expect("fragmented final response should complete after 103"); + assert_eq!( + response.get(&Value::string("body")), + Some(&Value::bytes(b"ok".to_vec())) + ); + } + + fn tls_fixture_configs() -> (Arc, Arc) { + let certified = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) + .expect("test certificate should generate"); + let cert_der = certified.cert.der().clone(); + let key_der = + rustls::pki_types::PrivateKeyDer::Pkcs8(certified.key_pair.serialize_der().into()); + let mut server_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der.clone()], key_der) + .expect("test server certificate should configure"); + server_config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + let mut roots = rustls::RootCertStore::empty(); + roots + .add(cert_der) + .expect("test certificate should be trusted"); + let client_config = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + assert!(client_config.alpn_protocols.is_empty()); + (Arc::new(server_config), Arc::new(client_config)) + } + + #[test] + fn https_requires_http11_alpn_and_preserves_sni_host_and_query() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + let (server_config, client_config) = tls_fixture_configs(); + let listener = runtime + .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) + .expect("TLS listener should bind"); + let address = listener.local_addr().expect("TLS listener address"); + let server = runtime.spawn(async move { + let (stream, _) = listener.accept().await.expect("TLS request should connect"); + let mut stream = tokio_rustls::TlsAcceptor::from(server_config) + .accept(stream) + .await + .expect("TLS handshake should succeed"); + assert_eq!( + stream.get_ref().1.alpn_protocol(), + Some(b"http/1.1".as_slice()) + ); + assert_eq!( + stream + .get_ref() + .1 + .server_name() + .expect("client should send SNI"), + "localhost" + ); + let mut request = Vec::new(); + let mut buffer = [0_u8; 256]; + loop { + let read = tokio::io::AsyncReadExt::read(&mut stream, &mut buffer) + .await + .expect("HTTPS request should be readable"); + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let request = String::from_utf8(request).expect("request should be ASCII"); + assert!(request.starts_with("GET /resource?q=rust HTTP/1.1\r\n")); + assert!(request.contains(&format!("host: localhost:{}\r\n", address.port()))); + tokio::io::AsyncWriteExt::write_all( + &mut stream, + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + ) + .await + .expect("HTTPS response should be writable"); + }); + let config = HttpConfig { + allowed_schemes: vec!["https".to_string()], + allowed_hosts: vec!["localhost".to_string()], + allowed_ports: vec![address.port()], + allow_private_ips: true, + max_response_body_bytes: 2, + ..HttpConfig::default() + }; + let request = HttpRequest { + method: hyper::Method::GET, + url: format!("https://localhost:{}/resource?q=rust", address.port()) + .parse() + .expect("valid HTTPS URL"), + headers: Vec::new(), + body: None, + }; + let observer = ResponseReadObserver::default(); + let response = runtime + .block_on(execute_request_with_tls_config( + &config, + &request, + observer.clone(), + client_config, + )) + .expect("HTTPS request should complete"); + assert_eq!( + response.get(&Value::string("body")), + Some(&Value::bytes(b"ok".to_vec())) + ); + assert!(observer.max_raw_transport_read() > 0); + assert!(observer.max_raw_transport_read() <= 16_384 + 2_048 + 5); + runtime + .block_on(server) + .expect("TLS server should complete"); + } + + #[test] + fn accepted_tcp_with_stalled_tls_uses_the_connection_stage_deadline() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + let (_socket, _) = listener.accept().expect("TCP client should connect"); + std::thread::sleep(Duration::from_millis(200)); + }); + let config = HttpConfig { + allowed_schemes: vec!["https".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![address.port()], + allow_private_ips: true, + connect_timeout: Duration::from_millis(30), + request_timeout: Duration::from_secs(1), + ..HttpConfig::default() + }; + let request = HttpRequest { + method: hyper::Method::GET, + url: format!("https://{address}/") + .parse() + .expect("valid HTTPS URL"), + headers: Vec::new(), + body: None, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + let started = Instant::now(); + let error = runtime + .block_on(execute_request(&config, &request)) + .expect_err("stalled TLS must time out"); + assert!(error.to_string().contains("deadline exceeded")); + assert!(started.elapsed() < Duration::from_millis(150)); + server.join().expect("server should exit"); + } + + #[test] + fn request_deadline_caps_the_connection_stage_deadline() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + let (_socket, _) = listener.accept().expect("TCP client should connect"); + std::thread::sleep(Duration::from_millis(200)); + }); + let config = HttpConfig { + allowed_schemes: vec!["https".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![address.port()], + allow_private_ips: true, + connect_timeout: Duration::from_secs(1), + request_timeout: Duration::from_millis(30), + ..HttpConfig::default() + }; + let request = HttpRequest { + method: hyper::Method::GET, + url: format!("https://{address}/") + .parse() + .expect("valid HTTPS URL"), + headers: Vec::new(), + body: None, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + let started = Instant::now(); + let error = runtime + .block_on(execute_request(&config, &request)) + .expect_err("request deadline must cap stalled TLS"); + assert!(error.to_string().contains("deadline exceeded")); + assert!(started.elapsed() < Duration::from_millis(150)); + server.join().expect("server should exit"); + } + + #[test] + fn dropping_host_future_aborts_connection_and_closes_peer_promptly() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("runtime should build"); + runtime.block_on(async { + let (client, mut server) = tokio::io::duplex(4096); + let (response_written, response_ready) = tokio::sync::oneshot::channel(); + let mut pending = pending_connection_test( + client, + "http://example.test/pending".parse().expect("valid URL"), + ); + let task = tokio::spawn(async move { + let mut request = Vec::new(); + let mut buffer = [0_u8; 256]; + loop { + let read = tokio::io::AsyncReadExt::read(&mut server, &mut buffer) + .await + .expect("request should be readable"); + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + tokio::io::AsyncWriteExt::write_all( + &mut server, + b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\na", + ) + .await + .expect("partial response should be writable"); + response_written + .send(()) + .expect("response readiness should be observed"); + let read = tokio::time::timeout( + Duration::from_millis(100), + tokio::io::AsyncReadExt::read(&mut server, &mut buffer), + ) + .await + .expect("peer EOF should be prompt") + .expect("peer EOF read should succeed"); + assert_eq!(read, 0); + }); + assert!( + futures_util::poll!(&mut pending.future).is_pending(), + "request should remain pending on the partial body" + ); + response_ready + .await + .expect("partial response should become ready"); + assert!( + futures_util::poll!(&mut pending.future).is_pending(), + "request should still await the remaining body" + ); + drop(pending); + task.await.expect("peer should observe EOF"); + }); + } + + #[test] + fn head_and_bodyless_statuses_ignore_declared_body_lengths() { + for (method, response, expected_status) in [ + ( + hyper::Method::HEAD, + b"HTTP/1.1 200 OK\r\nContent-Length: 999\r\n\r\n".as_slice(), + 200, + ), + ( + hyper::Method::GET, + b"HTTP/1.1 204 No Content\r\nContent-Length: 999\r\n\r\n".as_slice(), + 204, + ), + ( + hyper::Method::GET, + b"HTTP/1.1 304 Not Modified\r\nContent-Length: 999\r\n\r\n".as_slice(), + 304, + ), + ] { + let (result, observer) = + execute_fixture_response_fragments_for(method, vec![response], 1); + let response = result.expect("bodyless response should succeed"); + assert_eq!( + response.get(&Value::string("status")), + Some(&Value::Int(expected_status)) + ); + assert_eq!( + response.get(&Value::string("body")), + Some(&Value::bytes(Vec::new())) + ); + assert_eq!(observer.body_read_calls(), 0); + } + } + + #[test] + fn chunked_response_accepts_trailers_without_adding_them_to_the_body() { + let (result, _) = execute_fixture_response_fragments( + vec![ + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nTrailer: X-Checksum\r\n\r\n", + b"2\r\nok\r\n0\r\nX-Checksum: yes\r\n\r\n", + ], + 2, + ); + let response = result.expect("chunked response with trailers should succeed"); + assert_eq!( + response.get(&Value::string("body")), + Some(&Value::bytes(b"ok".to_vec())) + ); + } + + #[test] + fn truncated_content_length_propagates_a_body_or_connection_error() { + let (result, _) = execute_fixture_response( + b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nConnection: close\r\n\r\nok", + 8, + ); + let error = result.expect_err("truncated response body must fail"); + let message = error.to_string(); + assert!( + message.contains("response read failed") || message.contains("connection failed"), + "unexpected error: {message}" + ); + } + + #[test] + fn oversized_response_head_is_rejected_by_the_hyper_buffer_bound() { + let oversized = format!( + "HTTP/1.1 200 OK\r\nX-Oversized: {}\r\nContent-Length: 0\r\n\r\n", + "a".repeat(70 * 1024) + ); + let response: &'static [u8] = Box::leak(oversized.into_bytes().into_boxed_slice()); + let (result, _) = execute_fixture_response(response, 1); + let error = result.expect_err("oversized response head must fail"); + let message = error.to_string(); + assert!( + message.contains("HTTP request failed") + || message.contains("connection failed before the response"), + "unexpected error: {message}" + ); + } + + #[test] + fn declared_oversized_body_is_rejected_before_body_transport_polling() { + let (result, observer) = execute_fixture_response( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nabcde", + 4, + ); + let error = result.expect_err("declared oversized body must fail"); + assert!(error.to_string().contains("response body exceeds limit")); + assert_eq!(observer.body_read_calls(), 0); + assert_eq!(observer.max_body_transport_read(), 0); + } + + #[test] + fn chunked_single_write_is_observed_only_through_remaining_plus_sentinel() { + let (result, observer) = execute_fixture_response( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5\r\nabcde\r\n0\r\n\r\n", + 4, + ); + let error = result.expect_err("chunked limit plus one must fail"); + assert!(error.to_string().contains("response body exceeds limit")); + assert!(observer.body_read_calls() > 0); + assert!(observer.max_body_transport_read() <= 5); + assert!(observer.max_application_chunk() <= 5); + } + + #[test] + fn unknown_length_body_at_exact_limit_succeeds() { + let (result, observer) = + execute_fixture_response(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nabcd", 4); + let response = result.expect("exact-limit body should succeed"); + assert_eq!( + response.get(&Value::string("body")), + Some(&Value::bytes(b"abcd".to_vec())) + ); + assert!(observer.max_body_transport_read() <= 5); + assert!(observer.max_application_chunk() <= 4); + } + + #[test] + fn unknown_length_body_at_limit_plus_one_reads_only_the_sentinel() { + let (result, observer) = + execute_fixture_response(b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nabcde", 4); + let error = result.expect_err("limit plus one body must fail"); + assert!(error.to_string().contains("response body exceeds limit")); + assert!(observer.max_body_transport_read() <= 5); + assert!(observer.max_application_chunk() <= 5); + } + + #[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 websocket_policy_keeps_host_port_and_address_checks() { + let config = HttpConfig { + allowed_schemes: vec!["wss".to_string()], + allowed_hosts: vec!["example.com".to_string()], + allowed_ports: vec![443], + ..HttpConfig::default() + }; + let denied_host = "wss://other.example/".parse().expect("valid URL"); + let denied_port = "wss://example.com:444/".parse().expect("valid URL"); + let private = "wss://127.0.0.1/".parse().expect("valid URL"); + assert!(validate_url(&config, SchemeFamily::WebSocket, &denied_host).is_err()); + assert!(validate_url(&config, SchemeFamily::WebSocket, &denied_port).is_err()); + let private_config = HttpConfig { + allowed_schemes: vec!["wss".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![443], + ..HttpConfig::default() + }; + assert!(validate_url(&private_config, SchemeFamily::WebSocket, &private).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") + )); + } +} diff --git a/src/builtins/runtime/http/policy.rs b/src/builtins/runtime/http/policy.rs new file mode 100644 index 00000000..53305c10 --- /dev/null +++ b/src/builtins/runtime/http/policy.rs @@ -0,0 +1,257 @@ +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, + #[allow(dead_code)] + WebSocket, +} + +impl SchemeFamily { + fn accepts(self, scheme: &str) -> bool { + match self { + Self::Http => matches!(scheme, "http" | "https"), + Self::WebSocket => matches!(scheme, "ws" | "wss"), + } + } +} + +#[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) 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()) +} diff --git a/src/builtins/runtime/http/request.rs b/src/builtins/runtime/http/request.rs new file mode 100644 index 00000000..a7ea835d --- /dev/null +++ b/src/builtins/runtime/http/request.rs @@ -0,0 +1,941 @@ +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 super::HttpRequestContext; +use super::config::HttpConfig; +use super::policy::{SchemeFamily, request_deadline, resolve_url, with_deadline}; +use crate::builtins::runtime::VmMap; +use crate::vm::{Value, VmError, VmResult}; + +#[derive(Clone, Default)] +pub(super) struct ResponseReadObserver { + inner: Arc, +} + +#[derive(Default)] +struct ResponseReadMetrics { + phase: AtomicU8, + transport_waker: AtomicWaker, + remaining_body_bytes: AtomicUsize, + body_read_calls: AtomicUsize, + max_body_transport_read: AtomicUsize, + max_raw_transport_read: AtomicUsize, + max_application_chunk: 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 observe_transport_read(&self, bytes: usize) { + if self.body_is_admitted() { + self.inner.body_read_calls.fetch_add(1, Ordering::AcqRel); + self.inner + .max_body_transport_read + .fetch_max(bytes, Ordering::AcqRel); + } + } + + fn observe_raw_transport_read(&self, bytes: usize) { + self.inner + .max_raw_transport_read + .fetch_max(bytes, Ordering::AcqRel); + } + + pub(super) fn observe_application_chunk(&self, bytes: usize) { + self.inner + .max_application_chunk + .fetch_max(bytes, Ordering::AcqRel); + self.inner + .remaining_body_bytes + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |remaining| { + Some(remaining.saturating_sub(bytes)) + }) + .expect("response body remaining-byte update cannot fail"); + } + + #[cfg(test)] + pub(super) fn body_read_calls(&self) -> usize { + self.inner.body_read_calls.load(Ordering::Acquire) + } + + #[cfg(test)] + pub(super) fn max_body_transport_read(&self) -> usize { + self.inner.max_body_transport_read.load(Ordering::Acquire) + } + + #[cfg(test)] + pub(super) fn max_raw_transport_read(&self) -> usize { + self.inner.max_raw_transport_read.load(Ordering::Acquire) + } + + #[cfg(test)] + pub(super) fn max_application_chunk(&self) -> usize { + self.inner.max_application_chunk.load(Ordering::Acquire) + } +} + +// 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, + observer: ResponseReadObserver, +} + +impl RawReadCapIo { + fn new(inner: T, observer: ResponseReadObserver) -> Self { + Self { inner, observer } + } +} + +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); + } + this.observer.observe_raw_transport_read(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, + header_complete: bool, + 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, + header_complete: false, + status_prefix: [0; 12], + status_prefix_len: 0, + } + } + + fn observe_head_byte(&mut self, byte: u8) { + 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.header_bytes = self.header_bytes.saturating_add(1); + if self.header_bytes < 4 || self.header_suffix != *b"\r\n\r\n" { + return; + } + + 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(); + } + } +} + +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 mut bounded = buf.take(this.observer.transport_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(); + for byte in &bounded.filled()[..read] { + this.observe_head_byte(*byte); + } + unsafe { + buf.assume_init(initialized); + buf.set_filled(before + read); + } + this.observer.observe_transport_read(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>, +} + +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(); + 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")); + }; + 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")); + } + + 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}'" + ))), + } +} + +pub(super) async fn perform_buffered_request( + context: HttpRequestContext, + request: VmMap, +) -> VmResult { + let request = parse_request(&request, &context.config)?; + let deadline = request_deadline(context.config.request_timeout)?; + with_deadline( + deadline, + execute_request_until( + &context.config, + &request, + ResponseReadObserver::default(), + deadline, + None, + ), + ) + .await +} + +#[cfg(test)] +pub(super) async fn execute_request(config: &HttpConfig, request: &HttpRequest) -> VmResult { + let deadline = request_deadline(config.request_timeout)?; + with_deadline( + deadline, + execute_request_until( + config, + request, + ResponseReadObserver::default(), + deadline, + None, + ), + ) + .await +} + +#[cfg(test)] +pub(super) async fn execute_request_with_observer( + config: &HttpConfig, + request: &HttpRequest, + observer: ResponseReadObserver, +) -> VmResult { + let deadline = request_deadline(config.request_timeout)?; + with_deadline( + deadline, + execute_request_until(config, request, observer, deadline, None), + ) + .await +} + +#[cfg(test)] +pub(super) async fn execute_request_with_tls_config( + config: &HttpConfig, + request: &HttpRequest, + observer: ResponseReadObserver, + tls_config: Arc, +) -> VmResult { + let deadline = request_deadline(config.request_timeout)?; + with_deadline( + deadline, + execute_request_until(config, request, observer, deadline, Some(tls_config)), + ) + .await +} + +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 origin = url.origin(); + let mut response = send_request( + &method, + &url, + &resolved, + &headers, + body.as_deref(), + ConnectionStage { + observer: observer.clone(), + deadline: connect_deadline, + tls_config: tls_config.clone(), + }, + ) + .await?; + 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}")))?; + if next_url.origin() != origin { + headers.retain(|(name, _)| { + name != hyper::header::AUTHORIZATION && name != hyper::header::COOKIE + }); + } + if response.response().status() == hyper::StatusCode::SEE_OTHER + || ((response.response().status() == hyper::StatusCode::MOVED_PERMANENTLY + || response.response().status() == hyper::StatusCode::FOUND) + && method != hyper::Method::GET + && method != hyper::Method::HEAD) + { + method = hyper::Method::GET; + body = None; + } + 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 { + return self + .response + .body_mut() + .frame() + .await + .transpose() + .map_err(|error| { + VmError::HostError(format!("HTTP response read failed: {error}")) + }); + }; + 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) => { + return frame.transpose().map_err(|error| { + VmError::HostError(format!("HTTP response read failed: {error}")) + }); + } + 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 + ) +} + +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, + deadline: Option, +) -> 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 { + let mut connect_deadline = Instant::now() + .checked_add(config.connect_timeout) + .ok_or_else(|| { + VmError::HostError("HTTP connect_timeout cannot form a deadline".to_string()) + })?; + if let Some(deadline) = deadline { + connect_deadline = connect_deadline.min(deadline); + } + let resolved = with_deadline( + connect_deadline, + resolve_url(config, SchemeFamily::Http, &url), + ) + .await?; + let origin = url.origin(); + let response = send_request( + &method, + &url, + &resolved, + &headers, + body.as_deref(), + ConnectionStage { + observer: observer.clone(), + deadline: connect_deadline, + tls_config: None, + }, + ) + .await?; + 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()))?; + 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)?; + if next_url.origin() != origin { + headers.retain(|(name, _)| { + name != hyper::header::AUTHORIZATION && name != hyper::header::COOKIE + }); + } + if response.response().status() == hyper::StatusCode::SEE_OTHER + || ((response.response().status() == hyper::StatusCode::MOVED_PERMANENTLY + || response.response().status() == hyper::StatusCode::FOUND) + && method == hyper::Method::POST) + { + method = hyper::Method::GET; + body = None; + } + 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 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, + 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, + 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?; + stream + .set_nodelay(true) + .map_err(|error| VmError::HostError(format!("HTTP request failed: {error}")))?; + + let raw = RawReadCapIo::new(stream, observer.clone()); + 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)).await + } else { + send_over_io(method, url, headers, body, ReadCapIo::new(raw, observer)).await + } +} + +#[cfg(test)] +pub(super) struct PendingConnectionTest { + pub(super) future: Pin>>>, +} + +#[cfg(test)] +pub(super) fn pending_connection_test( + io: tokio::io::DuplexStream, + url: url::Url, +) -> PendingConnectionTest { + let request = HttpRequest { + method: hyper::Method::GET, + url, + headers: Vec::new(), + body: None, + }; + let observer = ResponseReadObserver::default(); + PendingConnectionTest { + future: Box::pin(async move { + let mut response = send_over_io( + &request.method, + &request.url, + &request.headers, + None, + ReadCapIo::new(RawReadCapIo::new(io, observer.clone()), observer.clone()), + ) + .await?; + observer.admit_body(1024); + while response.next_frame().await?.is_some() {} + Ok(VmMap::default()) + }), + } +} + +async fn send_over_io( + method: &hyper::Method, + url: &url::Url, + headers: &[(hyper::header::HeaderName, hyper::header::HeaderValue)], + body: Option<&[u8]>, + io: ReadCapIo, +) -> 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) + .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); + let (response, connection) = { + let response = sender.send_request(request); + tokio::pin!(response); + 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(OwnedResponse { + connection, + response, + }) +} diff --git a/src/builtins/runtime/http/sse.rs b/src/builtins/runtime/http/sse.rs new file mode 100644 index 00000000..18d75748 --- /dev/null +++ b/src/builtins/runtime/http/sse.rs @@ -0,0 +1,899 @@ +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +use pd_host_function::pd_host_function; + +use super::request::{ + HttpRequest, OwnedResponse, ResponseReadObserver, open_stream_response, parse_request, + response_header_entries, +}; +use super::{HttpRequestContext, policy}; +use crate::builtins::runtime::typed::VmMapHandle; +use crate::builtins::runtime::{HostCallResult, VmCallable, VmMap}; +use crate::vm::{ + CallOutcome, HostStreamAction, HostStreamDriver, HostStreamPoll, Value, Vm, VmError, VmResult, +}; + +#[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()) +} + +type OpenFuture = Pin> + Send>>; +type FrameFuture = Pin< + Box< + dyn Future< + Output = ( + OwnedResponse, + VmResult>>, + ), + > + Send, + >, +>; + +enum DriverState { + Opening { + future: OpenFuture, + idle_deadline: Instant, + timeout: Option>>, + }, + Reading { + future: FrameFuture, + idle_deadline: Instant, + timeout: Pin>, + }, + Ready(OwnedResponse), + Closed, +} + +struct SseDriver { + state: DriverState, + parser: SseParser, + chunk: Option, + chunk_offset: usize, + eof_pending: bool, + config: super::HttpConfig, + observer: ResponseReadObserver, + permit: Option, + deadline: Instant, + status: Option, + headers: Option>, + url: Option, + items: i64, + bytes_received: i64, +} + +impl Drop for SseDriver { + fn drop(&mut self) { + self.retire(); + } +} + +impl SseDriver { + fn new(context: HttpRequestContext, request: HttpRequest, deadline: Instant) -> Self { + let super::HttpRequestContext { config, _permit } = context; + let observer = ResponseReadObserver::default(); + let open_config = config.clone(); + let open_observer = observer.clone(); + let future = Box::pin(async move { + open_stream_response(&open_config, &request, open_observer, Some(deadline)).await + }); + let idle_deadline = Instant::now() + .checked_add(config.stream_idle_timeout) + .expect("validated idle timeout"); + Self { + state: DriverState::Opening { + future, + idle_deadline, + timeout: None, + }, + parser: SseParser::new( + config.max_sse_line_bytes, + config.max_stream_item_bytes, + config.max_stream_total_bytes, + ), + chunk: None, + chunk_offset: 0, + eof_pending: false, + config, + observer, + permit: Some(_permit), + deadline, + status: None, + headers: None, + url: None, + items: 0, + bytes_received: 0, + } + } + + fn validate_response(&mut self, response: &OwnedResponse, url: url::Url) -> VmResult { + 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(), + ) + })?; + debug_assert!(content_type.eq_ignore_ascii_case("text/event-stream")); + let headers = std::sync::Arc::new(VmMap::from_entries(response_header_entries( + response.response().headers(), + ))); + self.status = Some(status); + self.headers = Some(std::sync::Arc::clone(&headers)); + self.url = Some(url.clone()); + self.observer.admit_body(self.config.max_stream_total_bytes); + Ok(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())), + ])) + } + + fn event_value(event: SseEvent) -> Value { + 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)), + ]) + } + + fn summary(&self, outcome: &str) -> Value { + map_value(vec![ + ("outcome", Value::string(outcome)), + ( + "status", + Value::Int(i64::from( + self.status.expect("summary requires open status").as_u16(), + )), + ), + ( + "headers", + Value::Map( + self.headers + .as_ref() + .expect("summary requires headers") + .clone(), + ), + ), + ( + "url", + Value::string(self.url.as_ref().expect("summary requires URL").as_str()), + ), + ("items", Value::Int(self.items)), + ("bytes_received", Value::Int(self.bytes_received)), + ("bytes_sent", Value::Int(0)), + ]) + } + + fn retire(&mut self) { + self.state = DriverState::Closed; + self.chunk = None; + self.eof_pending = false; + self.permit.take(); + } + + fn ensure_before_deadline(&mut self) -> VmResult<()> { + if Instant::now() >= self.deadline { + self.retire(); + return Err(VmError::HostError( + "SSE total deadline exceeded".to_string(), + )); + } + Ok(()) + } +} + +impl HostStreamDriver for SseDriver { + fn poll_next(&mut self, cx: &mut Context<'_>) -> Poll> { + loop { + if let Err(error) = self.ensure_before_deadline() { + return Poll::Ready(Err(error)); + } + if let Some(chunk) = self.chunk.as_ref() { + let (consumed, event) = + self.parser.push_until_event(&chunk[self.chunk_offset..])?; + self.chunk_offset += consumed; + if self.chunk_offset == chunk.len() { + self.chunk = None; + self.chunk_offset = 0; + } + if let Some(event) = event { + return Poll::Ready(Ok(HostStreamPoll::Item(Self::event_value(event)))); + } + } + if self.eof_pending { + // `finish` only validates and cleans up: it can surface a + // partial BOM/UTF-8 or line-limit error, but it can never + // dispatch an event because EventSource dispatch requires a + // blank line and EOF discards a partial final event. + self.parser.finish()?; + self.eof_pending = false; + self.state = DriverState::Closed; + return Poll::Ready(Ok(HostStreamPoll::Item(map_value(vec![( + "kind", + Value::string("end"), + )])))); + } + match &mut self.state { + DriverState::Opening { + future, + idle_deadline, + timeout, + } => { + let open_deadline = self.deadline.min(*idle_deadline); + let timeout = timeout.get_or_insert_with(|| { + Box::pin(tokio::time::sleep_until(tokio::time::Instant::from_std( + open_deadline, + ))) + }); + if timeout.as_mut().poll(cx).is_ready() { + let total_expired = self.deadline <= *idle_deadline; + self.retire(); + return Poll::Ready(Err(VmError::HostError( + if total_expired { + "SSE total deadline exceeded" + } else { + "SSE stream idle timeout while opening response" + } + .to_string(), + ))); + } + match future.as_mut().poll(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => { + self.retire(); + return Poll::Ready(Err(error)); + } + Poll::Ready(Ok((response, url))) => { + let open = match self.validate_response(&response, url) { + Ok(open) => open, + Err(error) => { + self.retire(); + return Poll::Ready(Err(error)); + } + }; + self.state = DriverState::Ready(response); + return Poll::Ready(Ok(HostStreamPoll::Item(open))); + } + } + } + DriverState::Ready(_) => { + let DriverState::Ready(mut response) = + std::mem::replace(&mut self.state, DriverState::Closed) + else { + unreachable!() + }; + let idle_deadline = Instant::now() + .checked_add(self.config.stream_idle_timeout) + .expect("validated idle timeout"); + let deadline = self.deadline.min(idle_deadline); + self.state = DriverState::Reading { + future: Box::pin(async move { + let frame = response.next_frame().await; + (response, frame) + }), + idle_deadline, + timeout: Box::pin(tokio::time::sleep_until( + tokio::time::Instant::from_std(deadline), + )), + }; + } + DriverState::Reading { + future, + idle_deadline, + timeout, + } => { + if timeout.as_mut().poll(cx).is_ready() { + let total_expired = self.deadline <= *idle_deadline; + self.retire(); + return Poll::Ready(Err(VmError::HostError( + if total_expired { + "SSE total deadline exceeded" + } else { + "SSE stream idle timeout" + } + .to_string(), + ))); + } + match future.as_mut().poll(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready((response, Err(error))) => { + drop(response); + self.retire(); + return Poll::Ready(Err(error)); + } + Poll::Ready((response, Ok(Some(frame)))) => { + self.state = DriverState::Ready(response); + if let Ok(data) = frame.into_data() { + self.parser.admit_chunk(data.len())?; + self.observer.observe_application_chunk(data.len()); + self.bytes_received = self + .bytes_received + .checked_add(i64::try_from(data.len()).map_err(|_| { + VmError::HostError( + "SSE byte count exceeds script int".into(), + ) + })?) + .ok_or_else(|| { + VmError::HostError( + "SSE byte count exceeds script int".into(), + ) + })?; + self.chunk = Some(data); + self.chunk_offset = 0; + } + } + Poll::Ready((response, Ok(None))) => { + drop(response); + self.eof_pending = true; + } + } + } + DriverState::Closed => { + self.permit.take(); + return Poll::Ready(Ok(HostStreamPoll::Complete(self.summary("eof")))); + } + } + } + } + + fn apply_action(&mut self, action: Value) -> VmResult { + self.ensure_before_deadline()?; + let Value::Map(action) = action else { + self.retire(); + return Err(VmError::HostError( + "SSE callback action must be a map".to_string(), + )); + }; + let Some(Value::String(action)) = action.get(&Value::string("action")) else { + self.retire(); + return Err(VmError::HostError( + "SSE callback action must contain string 'action'".to_string(), + )); + }; + self.items = self + .items + .checked_add(1) + .ok_or_else(|| VmError::HostError("SSE item count exceeds script int".to_string()))?; + match action.as_str() { + "continue" => Ok(HostStreamAction::Continue), + "stop" => { + let summary = self.summary("stopped"); + self.retire(); + Ok(HostStreamAction::Complete(summary)) + } + other => { + let error = VmError::HostError(format!("invalid SSE callback action '{other}'")); + self.retire(); + Err(error) + } + } + } +} + +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))) +} + +/// 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_impl( + 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.as_ref())?; + let (context, deadline) = HttpRequestContext::capture_stream(vm, script_timeout, "SSE")?; + let mut request = parse_request(request.as_ref(), &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"), + )); + } + match vm.submit_callable_stream(callback, SseDriver::new(context, request, deadline))? { + CallOutcome::Pending(op_id) => Ok(HostCallResult::Pending(op_id)), + _ => Err(VmError::InvalidFrameState( + "callable stream admission returned a non-pending outcome", + )), + } +} + +#[cfg(test)] +mod tests { + use super::{SseEvent, SseParser}; + + 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_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), + ] + ); + } +} diff --git a/src/builtins/runtime/http/websocket.rs b/src/builtins/runtime/http/websocket.rs new file mode 100644 index 00000000..205ed499 --- /dev/null +++ b/src/builtins/runtime/http/websocket.rs @@ -0,0 +1,1789 @@ +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +use futures_util::{Sink, Stream}; +use pd_host_function::pd_host_function; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::{HeaderName, HeaderValue, StatusCode}; +use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; +use tokio_tungstenite::tungstenite::protocol::{CloseFrame, Message, WebSocketConfig}; + +use super::super::typed::VmMapHandle; +use super::super::{HostCallResult, VmCallable, VmMap}; +use super::HttpRequestContext; +use super::config::HttpConfig; +use super::policy::{ConnectionPermit, SchemeFamily, request_deadline, resolve_url, with_deadline}; +use crate::vm::{ + CallOutcome, HostStreamAction, HostStreamDriver, HostStreamPoll, Value, Vm, VmError, VmResult, +}; + +trait WebSocketIo: AsyncRead + AsyncWrite + Unpin + Send {} +impl WebSocketIo for T {} +type BoxIo = Box; +type Socket = WebSocketStream; +type ConnectFuture = Pin> + Send>>; + +struct CloseAckIo { + inner: BoxIo, + override_frame: Option, + collected: Vec, + queued_output: Vec, + output_written: usize, + failure: Option, + shutdown_flushed: bool, +} + +#[derive(Clone, Copy)] +struct CloseAckFailure { + kind: io::ErrorKind, + message: &'static str, +} + +enum CloseFrameParse { + Incomplete, + Complete(usize), +} + +impl CloseAckIo { + const MAX_CLOSE_FRAME_BYTES: usize = 2 + 4 + 125; + const MAX_QUEUED_OUTPUT_BYTES: usize = Self::MAX_CLOSE_FRAME_BYTES * 2; + + fn new(inner: BoxIo) -> Self { + Self { + inner, + override_frame: None, + collected: Vec::with_capacity(Self::MAX_CLOSE_FRAME_BYTES), + queued_output: Vec::with_capacity(Self::MAX_QUEUED_OUTPUT_BYTES), + output_written: 0, + failure: None, + shutdown_flushed: false, + } + } + + fn set_override(&mut self, frame: CloseFrame) { + debug_assert!(self.override_frame.is_none()); + debug_assert!(self.collected.is_empty()); + debug_assert!(self.queued_output.is_empty()); + self.override_frame = Some(frame); + } + + fn failure(&self) -> Option { + self.failure + .map(|failure| io::Error::new(failure.kind, failure.message)) + } + + fn fail(&mut self, kind: io::ErrorKind, message: &'static str) -> Poll> { + self.failure = Some(CloseAckFailure { kind, message }); + Poll::Ready(Err(io::Error::new(kind, message))) + } + + fn parse_collected_frame(&self) -> io::Result { + let Some(&first) = self.collected.first() else { + return Ok(CloseFrameParse::Incomplete); + }; + if first & 0x80 == 0 || first & 0x70 != 0 || first & 0x0f != 0x08 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected a final WebSocket close acknowledgment without reserved bits", + )); + } + let Some(&second) = self.collected.get(1) else { + return Ok(CloseFrameParse::Incomplete); + }; + if second & 0x80 == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "expected a masked WebSocket close acknowledgment", + )); + } + let payload_len = usize::from(second & 0x7f); + if payload_len > 125 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "WebSocket close acknowledgment payload exceeds 125 bytes", + )); + } + let frame_len = 6 + payload_len; + if self.collected.len() < frame_len { + return Ok(CloseFrameParse::Incomplete); + } + if payload_len == 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "WebSocket close acknowledgment has a one-byte payload", + )); + } + if payload_len >= 2 { + let mask: [u8; 4] = self.collected[2..6] + .try_into() + .expect("four-byte WebSocket mask"); + let payload = self.collected[6..frame_len] + .iter() + .enumerate() + .map(|(index, byte)| byte ^ mask[index & 3]) + .collect::>(); + if std::str::from_utf8(&payload[2..]).is_err() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "WebSocket close acknowledgment payload is invalid", + )); + } + } + Ok(CloseFrameParse::Complete(frame_len)) + } + + fn finish_collected_frame(&mut self, frame_len: usize) -> io::Result<()> { + let frame = self + .override_frame + .take() + .expect("armed close acknowledgment override"); + let payload_len = 2 + frame.reason.len(); + if payload_len > 125 || !valid_close_code(frame.code.into()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "replacement WebSocket close acknowledgment is invalid", + )); + } + let mask: [u8; 4] = self.collected[2..6] + .try_into() + .expect("four-byte WebSocket mask"); + let trailing_len = self.collected.len() - frame_len; + let replacement_len = 6 + payload_len; + if replacement_len + trailing_len > Self::MAX_QUEUED_OUTPUT_BYTES { + return Err(io::Error::new( + io::ErrorKind::OutOfMemory, + "WebSocket close acknowledgment output exceeds adapter bound", + )); + } + let mut payload = Vec::with_capacity(2 + frame.reason.len()); + payload.extend_from_slice(&u16::from(frame.code).to_be_bytes()); + payload.extend_from_slice(frame.reason.as_bytes()); + self.queued_output.clear(); + self.queued_output + .extend_from_slice(&[0x88, 0x80 | payload.len() as u8]); + self.queued_output.extend_from_slice(&mask); + self.queued_output.extend( + payload + .into_iter() + .enumerate() + .map(|(index, byte)| byte ^ mask[index & 3]), + ); + self.queued_output + .extend_from_slice(&self.collected[frame_len..]); + self.collected.clear(); + self.output_written = 0; + Ok(()) + } + + fn poll_drain_output(&mut self, cx: &mut Context<'_>) -> Poll> { + while self.output_written < self.queued_output.len() { + match Pin::new(&mut self.inner) + .poll_write(cx, &self.queued_output[self.output_written..]) + { + Poll::Ready(Ok(0)) => { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::WriteZero, + "failed to write WebSocket close acknowledgment", + ))); + } + Poll::Ready(Ok(written)) => self.output_written += written, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Pending => return Poll::Pending, + } + } + self.queued_output.clear(); + self.output_written = 0; + Poll::Ready(Ok(())) + } +} + +impl AsyncRead for CloseAckIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for CloseAckIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if let Some(error) = self.failure() { + return Poll::Ready(Err(error)); + } + if !self.queued_output.is_empty() { + match self.poll_drain_output(cx) { + Poll::Ready(Ok(())) => {} + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Pending => return Poll::Pending, + } + } + if self.override_frame.is_none() { + self.shutdown_flushed = false; + return Pin::new(&mut self.inner).poll_write(cx, buf); + } + if buf.is_empty() { + return Poll::Ready(Ok(0)); + } + let available = Self::MAX_CLOSE_FRAME_BYTES - self.collected.len(); + if available == 0 { + return self.fail( + io::ErrorKind::InvalidData, + "WebSocket close acknowledgment exceeds adapter bound", + ); + } + let accepted = available.min(buf.len()); + self.collected.extend_from_slice(&buf[..accepted]); + match self.parse_collected_frame() { + Ok(CloseFrameParse::Incomplete) => {} + Ok(CloseFrameParse::Complete(frame_len)) => { + if self.finish_collected_frame(frame_len).is_err() { + return self.fail( + io::ErrorKind::InvalidData, + "replacement WebSocket close acknowledgment is invalid", + ); + } + } + Err(_) => { + return self.fail( + io::ErrorKind::InvalidData, + "invalid WebSocket close acknowledgment frame", + ); + } + } + self.shutdown_flushed = false; + Poll::Ready(Ok(accepted)) + } + + fn is_write_vectored(&self) -> bool { + false + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if let Some(error) = self.failure() { + return Poll::Ready(Err(error)); + } + if self.override_frame.is_some() { + return self.fail( + io::ErrorKind::UnexpectedEof, + "incomplete WebSocket close acknowledgment frame", + ); + } + match self.poll_drain_output(cx) { + Poll::Ready(Ok(())) => {} + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Pending => return Poll::Pending, + } + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if let Some(error) = self.failure() { + return Poll::Ready(Err(error)); + } + if self.override_frame.is_some() { + return self.fail( + io::ErrorKind::UnexpectedEof, + "incomplete WebSocket close acknowledgment frame", + ); + } + match self.poll_drain_output(cx) { + Poll::Ready(Ok(())) => {} + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Pending => return Poll::Pending, + } + if !self.shutdown_flushed { + match Pin::new(&mut self.inner).poll_flush(cx) { + Poll::Ready(Ok(())) => self.shutdown_flushed = true, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Pending => return Poll::Pending, + } + } + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +#[derive(Clone)] +struct WebSocketRequest { + url: url::Url, + headers: Vec<(HeaderName, HeaderValue)>, + protocols: Vec, +} + +struct ConnectedSocket { + socket: Socket, + status: u16, + headers: VmMap, + protocol: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ItemKind { + Open, + Text, + Binary, + Ping, + Pong, + Close, +} + +struct ActiveSocket { + socket: Socket, + status: u16, + headers: VmMap, + protocol: Option, + open_pending: bool, + current_item: Option, + outbound: Option, + flush_required: bool, + local_closing: bool, + complete_after_flush: bool, + close_deadline: Option, + close_sleep: Option>>, + idle_sleep: Option>>, +} + +enum DriverState { + Connecting(ConnectFuture), + Active(Box), + Finished, +} + +struct WebSocketDriver { + config: HttpConfig, + request: WebSocketRequest, + _permit: ConnectionPermit, + state: DriverState, + items: usize, + bytes_received: usize, + bytes_sent: usize, + call_deadline: Option, + call_sleep: Option>>, +} + +/// Opens a bounded WebSocket session and serializes callback actions with socket polling. +#[pd_host_function(name = "http::client::websocket")] +pub(super) fn builtin_http_client_websocket_impl( + vm: &mut Vm, + request: VmMapHandle, + callback: VmCallable VmMap>, +) -> VmResult> { + let callback = callback.into_value(); + vm.validate_stream_callback_value(&callback)?; + + let script_timeout = parse_websocket_timeout(request.as_ref())?; + let (context, deadline) = HttpRequestContext::capture_stream(vm, script_timeout, "WebSocket")?; + let request = parse_websocket_request(request.as_ref(), &context.config)?; + let (config, permit) = context.into_parts(); + let driver = WebSocketDriver::new(config, request, permit, Some(deadline))?; + match vm.submit_callable_stream(callback, driver)? { + CallOutcome::Pending(op_id) => Ok(HostCallResult::Pending(op_id)), + _ => Err(VmError::InvalidFrameState( + "WebSocket callable stream did not suspend", + )), + } +} + +impl WebSocketDriver { + fn new( + config: HttpConfig, + request: WebSocketRequest, + permit: ConnectionPermit, + externally_bounded_deadline: Option, + ) -> VmResult { + let connect_deadline = request_deadline(config.connect_timeout)?; + let connect_deadline = externally_bounded_deadline + .map_or(connect_deadline, |deadline| deadline.min(connect_deadline)); + let future = connect_socket(config.clone(), request.clone(), connect_deadline); + Ok(Self { + config, + request, + _permit: permit, + state: DriverState::Connecting(Box::pin(future)), + items: 0, + bytes_received: 0, + bytes_sent: 0, + call_deadline: externally_bounded_deadline, + call_sleep: None, + }) + } + + fn summary(&self, outcome: &str, active: &ActiveSocket) -> Value { + Value::Map(Arc::new(VmMap::from_entries(vec![ + (Value::string("outcome"), Value::string(outcome)), + ( + Value::string("status"), + Value::Int(i64::from(active.status)), + ), + ( + Value::string("headers"), + Value::Map(Arc::new(active.headers.clone())), + ), + ( + Value::string("url"), + Value::string(self.request.url.as_str()), + ), + (Value::string("items"), Value::Int(self.items as i64)), + ( + Value::string("bytes_received"), + Value::Int(self.bytes_received as i64), + ), + ( + Value::string("bytes_sent"), + Value::Int(self.bytes_sent as i64), + ), + ]))) + } + + fn check_total_deadline(&self) -> VmResult<()> { + if self + .call_deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + return Err(VmError::HostError( + "WebSocket call deadline exceeded".to_string(), + )); + } + Ok(()) + } + + fn poll_total_deadline(&mut self, cx: &mut Context<'_>) -> VmResult<()> { + self.check_total_deadline()?; + if self.call_sleep.is_none() + && let Some(deadline) = self.call_deadline + { + self.call_sleep = Some(Box::pin(tokio::time::sleep_until( + tokio::time::Instant::from_std(deadline), + ))); + } + if self + .call_sleep + .as_mut() + .is_some_and(|sleep| sleep.as_mut().poll(cx).is_ready()) + { + return Err(VmError::HostError( + "WebSocket call deadline exceeded".to_string(), + )); + } + Ok(()) + } + + fn complete(&self, outcome: &str, active: &ActiveSocket) -> HostStreamPoll { + HostStreamPoll::Complete(self.summary(outcome, active)) + } + + fn poll_active( + &mut self, + cx: &mut Context<'_>, + active: &mut ActiveSocket, + ) -> Poll> { + if active.open_pending { + active.open_pending = false; + active.current_item = Some(ItemKind::Open); + self.items += 1; + return Poll::Ready(Ok(HostStreamPoll::Item(open_item( + active.status, + &active.headers, + &self.request.url, + active.protocol.as_deref(), + )))); + } + + if active.local_closing || active.complete_after_flush { + let deadline = active.close_deadline.ok_or(VmError::InvalidFrameState( + "WebSocket close handshake has no deadline", + ))?; + if Instant::now() >= deadline { + return Poll::Ready(Err(VmError::HostError( + "WebSocket close handshake timed out".to_string(), + ))); + } + let sleep = active.close_sleep.get_or_insert_with(|| { + Box::pin(tokio::time::sleep_until(tokio::time::Instant::from_std( + deadline, + ))) + }); + if sleep.as_mut().poll(cx).is_ready() { + return Poll::Ready(Err(VmError::HostError( + "WebSocket close handshake timed out".to_string(), + ))); + } + } + + if let Some(message) = active.outbound.take() { + match Pin::new(&mut active.socket).poll_ready(cx) { + Poll::Pending => { + active.outbound = Some(message); + return Poll::Pending; + } + Poll::Ready(Err(error)) => { + return Poll::Ready(Err(socket_error("WebSocket write failed", error))); + } + Poll::Ready(Ok(())) => {} + } + if let Err(error) = Pin::new(&mut active.socket).start_send(message) { + return Poll::Ready(Err(socket_error("WebSocket write failed", error))); + } + active.flush_required = true; + } + if active.flush_required { + match Pin::new(&mut active.socket).poll_flush(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => { + return Poll::Ready(Err(socket_error("WebSocket write failed", error))); + } + Poll::Ready(Ok(())) => active.flush_required = false, + } + } + if active.complete_after_flush { + return Poll::Ready(Ok(self.complete("closed", active))); + } + + if !active.local_closing { + if active.idle_sleep.is_none() { + active.idle_sleep = Some(Box::pin(tokio::time::sleep( + self.config.stream_idle_timeout, + ))); + } + if active + .idle_sleep + .as_mut() + .is_some_and(|sleep| sleep.as_mut().poll(cx).is_ready()) + { + return Poll::Ready(Err(VmError::HostError( + "WebSocket stream idle timeout exceeded".to_string(), + ))); + } + } + + const MAX_DISCARDED_MESSAGES_PER_POLL: usize = 32; + let mut discarded_messages = 0; + loop { + let message = match Pin::new(&mut active.socket).poll_next(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(None) => { + return Poll::Ready(Err(VmError::HostError( + "WebSocket transport ended without a close handshake".to_string(), + ))); + } + Poll::Ready(Some(Err(error))) => { + return Poll::Ready(Err(socket_error("WebSocket receive failed", error))); + } + Poll::Ready(Some(Ok(message))) => message, + }; + active.idle_sleep = None; + + if active.local_closing { + match message { + Message::Close(_) => { + return Poll::Ready(Ok(self.complete("closed", active))); + } + Message::Text(_) | Message::Binary(_) | Message::Ping(_) | Message::Pong(_) => { + discarded_messages += 1; + if discarded_messages == MAX_DISCARDED_MESSAGES_PER_POLL { + cx.waker().wake_by_ref(); + return Poll::Pending; + } + continue; + } + Message::Frame(_) => { + return Poll::Ready(Err(VmError::HostError( + "WebSocket exposed an unexpected raw frame".to_string(), + ))); + } + } + } + + if matches!(&message, Message::Text(_) | Message::Binary(_)) { + self.bytes_received = checked_application_counter( + self.bytes_received, + self.bytes_sent, + message.len(), + self.config.max_stream_total_bytes, + )?; + } + self.items += 1; + let (kind, item) = match message { + Message::Text(text) => ( + ItemKind::Text, + item_map(vec![ + ("kind", Value::string("text")), + ("text", Value::string(text.as_str())), + ]), + ), + Message::Binary(data) => ( + ItemKind::Binary, + item_map(vec![ + ("kind", Value::string("binary")), + ("data", Value::bytes(data.to_vec())), + ]), + ), + Message::Ping(data) => ( + ItemKind::Ping, + item_map(vec![ + ("kind", Value::string("ping")), + ("data", Value::bytes(data.to_vec())), + ]), + ), + Message::Pong(data) => ( + ItemKind::Pong, + item_map(vec![ + ("kind", Value::string("pong")), + ("data", Value::bytes(data.to_vec())), + ]), + ), + Message::Close(frame) => { + let (code, reason) = frame.map_or((Value::Null, String::new()), |frame| { + ( + Value::Int(u16::from(frame.code) as i64), + frame.reason.to_string(), + ) + }); + ( + ItemKind::Close, + item_map(vec![ + ("kind", Value::string("close")), + ("code", code), + ("reason", Value::string(reason)), + ]), + ) + } + Message::Frame(_) => { + return Poll::Ready(Err(VmError::HostError( + "WebSocket exposed an unexpected raw frame".to_string(), + ))); + } + }; + active.current_item = Some(kind); + return Poll::Ready(Ok(HostStreamPoll::Item(item))); + } + } +} + +impl HostStreamDriver for WebSocketDriver { + fn poll_next(&mut self, cx: &mut Context<'_>) -> Poll> { + if let Err(error) = self.poll_total_deadline(cx) { + self.state = DriverState::Finished; + return Poll::Ready(Err(error)); + } + loop { + match &mut self.state { + DriverState::Connecting(future) => match future.as_mut().poll(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(error)) => { + self.state = DriverState::Finished; + return Poll::Ready(Err(error)); + } + Poll::Ready(Ok(connected)) => { + self.state = DriverState::Active(Box::new(ActiveSocket { + socket: connected.socket, + status: connected.status, + headers: connected.headers, + protocol: connected.protocol, + open_pending: true, + current_item: None, + outbound: None, + flush_required: false, + local_closing: false, + complete_after_flush: false, + close_deadline: None, + close_sleep: None, + idle_sleep: None, + })); + } + }, + DriverState::Active(_) => { + let mut active = match std::mem::replace(&mut self.state, DriverState::Finished) + { + DriverState::Active(active) => active, + _ => unreachable!(), + }; + let result = self.poll_active(cx, &mut active); + // `poll_active` never touches `self.state`, which is + // `Finished` here; restore it only when the active socket + // must be resumed after a non-terminal poll. + if !matches!(result, Poll::Ready(Ok(HostStreamPoll::Complete(_)))) { + self.state = DriverState::Active(active); + } + return result; + } + DriverState::Finished => { + return Poll::Ready(Err(VmError::InvalidFrameState( + "WebSocket driver polled after completion", + ))); + } + } + } + } + + fn apply_action(&mut self, action: Value) -> VmResult { + self.check_total_deadline()?; + let summary_url = self.request.url.clone(); + let summary_items = self.items; + let summary_received = self.bytes_received; + let summary_sent = self.bytes_sent; + let close_timeout = self.config.websocket_close_timeout; + let call_deadline = self.call_deadline; + let DriverState::Active(active) = &mut self.state else { + return Err(VmError::InvalidFrameState( + "WebSocket action applied without an active connection", + )); + }; + let kind = active + .current_item + .take() + .ok_or(VmError::InvalidFrameState( + "WebSocket action applied without an item", + ))?; + let action_map = value_map(&action, "WebSocket callback action")?; + let action_name = required_string(action_map, "action", "WebSocket callback action")?; + + if action_name == "stop" { + let summary = summary_value( + "stopped", + active, + &summary_url, + summary_items, + summary_received, + summary_sent, + ); + self.state = DriverState::Finished; + return Ok(HostStreamAction::Complete(summary)); + } + + let outbound = match action_name.as_str() { + "continue" => match kind { + ItemKind::Ping => { + active.flush_required = true; + None + } + ItemKind::Close => { + active.complete_after_flush = true; + active.flush_required = true; + start_close_deadline( + &mut active.close_deadline, + Instant::now(), + close_timeout, + call_deadline, + )?; + None + } + _ => None, + }, + "send_text" if matches!(kind, ItemKind::Open | ItemKind::Text | ItemKind::Binary) => { + let text = required_string(action_map, "text", "WebSocket send_text action")?; + self.bytes_sent = validate_send_size( + &self.config, + self.bytes_received, + self.bytes_sent, + text.len(), + )?; + Some(Message::text(text)) + } + "send_binary" if matches!(kind, ItemKind::Open | ItemKind::Text | ItemKind::Binary) => { + let data = required_bytes(action_map, "data", "WebSocket send_binary action")?; + self.bytes_sent = validate_send_size( + &self.config, + self.bytes_received, + self.bytes_sent, + data.len(), + )?; + Some(Message::binary(data)) + } + "ping" if !matches!(kind, ItemKind::Ping | ItemKind::Close) => { + let data = required_bytes(action_map, "data", "WebSocket ping action")?; + validate_control_payload(&self.config, &data)?; + Some(Message::Ping(data.into())) + } + "pong" + if matches!( + kind, + ItemKind::Open | ItemKind::Text | ItemKind::Binary | ItemKind::Ping + ) => + { + let data = required_bytes(action_map, "data", "WebSocket pong action")?; + validate_control_payload(&self.config, &data)?; + Some(Message::Pong(data.into())) + } + "close" => { + let frame = parse_close_action(action_map)?; + let payload_len = 2 + frame.reason.len(); + validate_control_size(&self.config, payload_len)?; + if kind == ItemKind::Close { + active.socket.get_mut().set_override(frame); + active.complete_after_flush = true; + active.flush_required = true; + start_close_deadline( + &mut active.close_deadline, + Instant::now(), + close_timeout, + call_deadline, + )?; + None + } else { + active.local_closing = true; + start_close_deadline( + &mut active.close_deadline, + Instant::now(), + close_timeout, + call_deadline, + )?; + Some(Message::Close(Some(frame))) + } + } + _ => { + return Err(VmError::HostError(format!( + "WebSocket action '{action_name}' is invalid for the current item" + ))); + } + }; + active.outbound = outbound; + Ok(HostStreamAction::Continue) + } +} + +fn start_close_deadline( + close_deadline: &mut Option, + now: Instant, + close_timeout: Duration, + call_deadline: Option, +) -> VmResult<()> { + if close_deadline.is_some() { + return Ok(()); + } + let deadline = now.checked_add(close_timeout).ok_or_else(|| { + VmError::HostError("WebSocket close timeout cannot form a deadline".to_string()) + })?; + *close_deadline = Some(call_deadline.map_or(deadline, |call| call.min(deadline))); + Ok(()) +} + +async fn connect_socket( + config: HttpConfig, + request: WebSocketRequest, + deadline: Instant, +) -> VmResult { + connect_socket_with_tls_config(config, request, deadline, None).await +} + +async fn connect_socket_with_tls_config( + config: HttpConfig, + request: WebSocketRequest, + deadline: Instant, + test_tls_config: Option>, +) -> VmResult { + with_deadline(deadline, async move { + let resolved = resolve_url(&config, SchemeFamily::WebSocket, &request.url).await?; + let stream = tokio::net::TcpStream::connect(resolved.address) + .await + .map_err(|error| VmError::HostError(format!("WebSocket connect failed: {error}")))?; + stream + .set_nodelay(true) + .map_err(|error| VmError::HostError(format!("WebSocket connect failed: {error}")))?; + let io: BoxIo = if request.url.scheme() == "wss" { + let mut tls_config = if let Some(config) = test_tls_config { + (*config).clone() + } 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() + }; + 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("WebSocket TLS server name is invalid".to_string()) + })?; + let tls = tokio_rustls::TlsConnector::from(Arc::new(tls_config)) + .connect(server_name, stream) + .await + .map_err(|error| VmError::HostError(format!("WebSocket TLS failed: {error}")))?; + Box::new(tls) + } else { + Box::new(stream) + }; + + let mut handshake = request + .url + .as_str() + .into_client_request() + .map_err(|error| { + VmError::HostError(format!("WebSocket handshake setup failed: {error}")) + })?; + for (name, value) in &request.headers { + handshake.headers_mut().append(name, value.clone()); + } + if !request.protocols.is_empty() { + handshake.headers_mut().insert( + "sec-websocket-protocol", + HeaderValue::from_str(&request.protocols.join(", ")).map_err(|_| { + VmError::HostError("WebSocket protocols are invalid".to_string()) + })?, + ); + } + let ws_config = WebSocketConfig::default() + .read_buffer_size(config.max_websocket_frame_bytes.min(128 * 1024)) + .write_buffer_size(config.max_websocket_send_bytes.min(128 * 1024)) + .max_write_buffer_size(config.max_websocket_send_bytes.saturating_add(1024)) + .max_message_size(Some(config.max_stream_item_bytes)) + .max_frame_size(Some(config.max_websocket_frame_bytes)); + let io = CloseAckIo::new(io); + let (socket, response) = + tokio_tungstenite::client_async_with_config(handshake, io, Some(ws_config)) + .await + .map_err(|error| { + VmError::HostError(format!("WebSocket handshake failed: {error}")) + })?; + if response.status() != StatusCode::SWITCHING_PROTOCOLS { + return Err(VmError::HostError(format!( + "WebSocket handshake returned status {}", + response.status() + ))); + } + let selected = response + .headers() + .get("sec-websocket-protocol") + .map(|value| { + value.to_str().map(str::to_string).map_err(|_| { + VmError::HostError("WebSocket selected protocol is invalid".to_string()) + }) + }) + .transpose()?; + if selected + .as_ref() + .is_some_and(|selected| !request.protocols.iter().any(|offered| offered == selected)) + { + return Err(VmError::HostError( + "WebSocket server selected an unoffered protocol".to_string(), + )); + } + let headers = VmMap::from_entries( + response + .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(), + ); + Ok(ConnectedSocket { + socket, + status: response.status().as_u16(), + headers, + protocol: selected, + }) + }) + .await +} + +fn parse_websocket_request(map: &VmMap, config: &HttpConfig) -> VmResult { + let url = required_string(map, "url", "WebSocket request")? + .parse::() + .map_err(|error| VmError::HostError(format!("invalid WebSocket URL: {error}")))?; + super::policy::validate_url_policy(config, SchemeFamily::WebSocket, &url)?; + + let mut headers = Vec::new(); + match map.get(&Value::string("headers")) { + None | Some(Value::Null) => {} + Some(Value::Map(entries)) => { + for (name, value) in entries.iter() { + let Value::String(name) = name else { + return Err(VmError::TypeMismatch("WebSocket header name")); + }; + let Value::String(value) = value else { + return Err(VmError::TypeMismatch("WebSocket header value")); + }; + let lower = name.to_ascii_lowercase(); + if lower == "host" + || lower == "upgrade" + || lower == "connection" + || lower.starts_with("sec-websocket-") + { + return Err(VmError::HostError(format!( + "WebSocket header '{name}' is managed by the client" + ))); + } + let name = HeaderName::from_bytes(name.as_bytes()).map_err(|_| { + VmError::HostError(format!("invalid WebSocket header name '{name}'")) + })?; + let value = HeaderValue::from_str(value).map_err(|_| { + VmError::HostError(format!("invalid WebSocket header value for '{name}'")) + })?; + headers.push((name, value)); + } + } + Some(_) => return Err(VmError::TypeMismatch("WebSocket headers")), + } + + let protocols = match map.get(&Value::string("protocols")) { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(values)) => { + let mut protocols = Vec::with_capacity(values.len()); + for value in values.iter() { + let Value::String(protocol) = value else { + return Err(VmError::TypeMismatch("WebSocket protocol")); + }; + if protocol.is_empty() + || !protocol.bytes().all(|byte| { + matches!(byte, 0x21..=0x7e) + && !matches!( + byte, + b'(' | b')' + | b'<' + | b'>' + | b'@' + | b',' + | b';' + | b':' + | b'\\' + | b'"' + | b'/' + | b'[' + | b']' + | b'?' + | b'=' + | b'{' + | b'}' + ) + }) + || protocols + .iter() + .any(|existing| existing == protocol.as_str()) + { + return Err(VmError::HostError( + "WebSocket protocols must be unique HTTP tokens".to_string(), + )); + } + protocols.push(protocol.to_string()); + } + protocols + } + Some(_) => return Err(VmError::TypeMismatch("WebSocket protocols")), + }; + + Ok(WebSocketRequest { + url, + headers, + protocols, + }) +} + +fn parse_websocket_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("WebSocket timeout_ms")); + }; + let milliseconds = u64::try_from(*milliseconds) + .ok() + .filter(|milliseconds| *milliseconds > 0) + .ok_or_else(|| VmError::HostError("WebSocket timeout_ms must be positive".to_string()))?; + Ok(Some(Duration::from_millis(milliseconds))) +} + +fn open_item(status: u16, headers: &VmMap, url: &url::Url, protocol: Option<&str>) -> Value { + item_map(vec![ + ("kind", Value::string("open")), + ("status", Value::Int(i64::from(status))), + ("headers", Value::Map(Arc::new(headers.clone()))), + ("url", Value::string(url.as_str())), + ("protocol", protocol.map_or(Value::Null, Value::string)), + ]) +} + +fn item_map(entries: Vec<(&str, Value)>) -> Value { + Value::Map(Arc::new(VmMap::from_entries( + entries + .into_iter() + .map(|(key, value)| (Value::string(key), value)) + .collect(), + ))) +} + +fn summary_value( + outcome: &str, + active: &ActiveSocket, + url: &url::Url, + items: usize, + bytes_received: usize, + bytes_sent: usize, +) -> Value { + Value::Map(Arc::new(VmMap::from_entries(vec![ + (Value::string("outcome"), Value::string(outcome)), + ( + Value::string("status"), + Value::Int(i64::from(active.status)), + ), + ( + Value::string("headers"), + Value::Map(Arc::new(active.headers.clone())), + ), + (Value::string("url"), Value::string(url.as_str())), + (Value::string("items"), Value::Int(items as i64)), + ( + Value::string("bytes_received"), + Value::Int(bytes_received as i64), + ), + (Value::string("bytes_sent"), Value::Int(bytes_sent as i64)), + ]))) +} + +fn value_map<'a>(value: &'a Value, label: &'static str) -> VmResult<&'a VmMap> { + match value { + Value::Map(map) => Ok(map), + _ => Err(VmError::TypeMismatch(label)), + } +} + +fn required_string(map: &VmMap, key: &str, label: &'static str) -> VmResult { + match map.get(&Value::string(key)) { + Some(Value::String(value)) => Ok(value.to_string()), + Some(_) => Err(VmError::TypeMismatch(label)), + None => Err(VmError::HostError(format!("missing {label} field '{key}'"))), + } +} + +fn required_bytes(map: &VmMap, key: &str, label: &'static str) -> VmResult> { + match map.get(&Value::string(key)) { + Some(Value::Bytes(value)) => Ok(value.as_ref().clone()), + Some(_) => Err(VmError::TypeMismatch(label)), + None => Err(VmError::HostError(format!("missing {label} field '{key}'"))), + } +} + +fn validate_send_size( + config: &HttpConfig, + bytes_received: usize, + bytes_sent: usize, + size: usize, +) -> VmResult { + if size > config.max_websocket_send_bytes { + return Err(VmError::HostError( + "WebSocket send payload exceeds limit".to_string(), + )); + } + checked_application_counter( + bytes_sent, + bytes_received, + size, + config.max_stream_total_bytes, + ) +} + +fn checked_application_counter( + current: usize, + other: usize, + additional: usize, + maximum: usize, +) -> VmResult { + let updated = current.checked_add(additional).ok_or_else(|| { + VmError::HostError("WebSocket stream application byte count overflowed".to_string()) + })?; + let total = updated.checked_add(other).ok_or_else(|| { + VmError::HostError("WebSocket stream application byte count overflowed".to_string()) + })?; + if total > maximum { + return Err(VmError::HostError( + "WebSocket stream exceeds total byte limit".to_string(), + )); + } + Ok(updated) +} + +fn validate_control_payload(config: &HttpConfig, data: &[u8]) -> VmResult<()> { + validate_control_size(config, data.len()) +} + +fn validate_control_size(config: &HttpConfig, size: usize) -> VmResult<()> { + if size > config.max_websocket_send_bytes { + return Err(VmError::HostError( + "WebSocket send payload exceeds limit".to_string(), + )); + } + if size > 125 { + return Err(VmError::HostError( + "WebSocket control payload exceeds 125 bytes".to_string(), + )); + } + Ok(()) +} + +fn parse_close_action(map: &VmMap) -> VmResult { + let code = match map.get(&Value::string("code")) { + Some(Value::Int(code)) => u16::try_from(*code).ok(), + Some(_) => return Err(VmError::TypeMismatch("WebSocket close code")), + None => None, + } + .filter(|code| valid_close_code(*code)) + .ok_or_else(|| VmError::HostError("WebSocket close code is invalid".to_string()))?; + let reason = required_string(map, "reason", "WebSocket close action")?; + if reason.len() > 123 { + return Err(VmError::HostError( + "WebSocket close reason exceeds 123 bytes".to_string(), + )); + } + Ok(CloseFrame { + code: CloseCode::from(code), + reason: reason.into(), + }) +} + +fn valid_close_code(code: u16) -> bool { + matches!(code, 1000..=1003 | 1007..=1014 | 3000..=4999) +} + +fn socket_error(prefix: &str, error: tokio_tungstenite::tungstenite::Error) -> VmError { + VmError::HostError(format!("{prefix}: {error}")) +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::Mutex; + use std::time::Duration; + + use super::*; + use tokio::io::AsyncWriteExt; + + #[derive(Clone, Copy)] + enum ScriptStep { + Write(usize), + Pending, + Error(io::ErrorKind), + } + + #[derive(Default)] + struct ScriptState { + bytes: Vec, + writes: VecDeque, + flushes: VecDeque, + shutdowns: VecDeque, + events: Vec<&'static str>, + } + + struct ScriptedIo { + state: Arc>, + } + + impl AsyncRead for ScriptedIo { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut ReadBuf<'_>, + ) -> Poll> { + Poll::Pending + } + } + + impl AsyncWrite for ScriptedIo { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let mut state = self.state.lock().unwrap(); + state.events.push("write"); + match state + .writes + .pop_front() + .unwrap_or(ScriptStep::Write(buf.len())) + { + ScriptStep::Write(limit) => { + let written = limit.min(buf.len()); + state.bytes.extend_from_slice(&buf[..written]); + Poll::Ready(Ok(written)) + } + ScriptStep::Pending => { + cx.waker().wake_by_ref(); + Poll::Pending + } + ScriptStep::Error(kind) => Poll::Ready(Err(io::Error::new(kind, "scripted"))), + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut state = self.state.lock().unwrap(); + state.events.push("flush"); + match state.flushes.pop_front().unwrap_or(ScriptStep::Write(0)) { + ScriptStep::Write(_) => Poll::Ready(Ok(())), + ScriptStep::Pending => { + cx.waker().wake_by_ref(); + Poll::Pending + } + ScriptStep::Error(kind) => Poll::Ready(Err(io::Error::new(kind, "scripted"))), + } + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut state = self.state.lock().unwrap(); + state.events.push("shutdown"); + match state.shutdowns.pop_front().unwrap_or(ScriptStep::Write(0)) { + ScriptStep::Write(_) => Poll::Ready(Ok(())), + ScriptStep::Pending => { + cx.waker().wake_by_ref(); + Poll::Pending + } + ScriptStep::Error(kind) => Poll::Ready(Err(io::Error::new(kind, "scripted"))), + } + } + } + + fn scripted_adapter( + writes: impl IntoIterator, + ) -> (CloseAckIo, Arc>) { + let state = Arc::new(Mutex::new(ScriptState { + writes: writes.into_iter().collect(), + ..ScriptState::default() + })); + let io = ScriptedIo { + state: Arc::clone(&state), + }; + (CloseAckIo::new(Box::new(io)), state) + } + + fn masked_close(mask: [u8; 4], code: u16, reason: &str) -> Vec { + let mut payload = Vec::from(code.to_be_bytes()); + payload.extend_from_slice(reason.as_bytes()); + let mut frame = vec![0x88, 0x80 | payload.len() as u8]; + frame.extend_from_slice(&mask); + frame.extend( + payload + .into_iter() + .enumerate() + .map(|(index, byte)| byte ^ mask[index & 3]), + ); + frame + } + + fn custom_close() -> CloseFrame { + CloseFrame { + code: CloseCode::from(4001), + reason: "custom".into(), + } + } + + #[tokio::test] + async fn close_ack_adapter_serializes_code_1014_with_utf8_reason() { + let (mut io, state) = scripted_adapter([]); + io.set_override(CloseFrame { + code: CloseCode::from(1014), + reason: "代理错误".into(), + }); + let original = masked_close([1, 2, 3, 4], 1001, "away"); + + io.write_all(&original).await.unwrap(); + io.flush().await.unwrap(); + + assert_eq!( + state.lock().unwrap().bytes, + masked_close([1, 2, 3, 4], 1014, "代理错误") + ); + } + + #[tokio::test] + async fn close_ack_adapter_rejects_reserved_replacement_codes_without_output() { + for code in [1004, 1005, 1006, 1015] { + let (mut io, state) = scripted_adapter([]); + io.set_override(CloseFrame { + code: CloseCode::from(code), + reason: "reserved".into(), + }); + + assert_eq!( + io.write(&masked_close([4, 3, 2, 1], 1000, "peer")) + .await + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + assert!(state.lock().unwrap().bytes.is_empty()); + } + } + + #[tokio::test] + async fn close_ack_adapter_collects_a_frame_split_across_writes() { + let (mut io, state) = scripted_adapter([]); + io.set_override(custom_close()); + let original = masked_close([1, 2, 3, 4], 1000, "peer"); + + io.write_all(&original[..1]).await.unwrap(); + io.write_all(&original[1..4]).await.unwrap(); + io.write_all(&original[4..]).await.unwrap(); + assert!(state.lock().unwrap().bytes.is_empty()); + io.flush().await.unwrap(); + + assert_eq!( + state.lock().unwrap().bytes, + masked_close([1, 2, 3, 4], 4001, "custom") + ); + } + + #[tokio::test] + async fn close_ack_adapter_preserves_coalesced_trailing_bytes_after_replacement() { + let (mut io, state) = scripted_adapter([]); + io.set_override(custom_close()); + let original = masked_close([9, 8, 7, 6], 1000, "peer"); + let trailing = [0x89, 0x80, 4, 3, 2, 1]; + let mut coalesced = original.clone(); + coalesced.extend_from_slice(&trailing); + + assert_eq!(io.write(&coalesced).await.unwrap(), coalesced.len()); + io.flush().await.unwrap(); + + let mut expected = masked_close([9, 8, 7, 6], 4001, "custom"); + expected.extend_from_slice(&trailing); + assert_eq!(state.lock().unwrap().bytes, expected); + } + + #[tokio::test] + async fn close_ack_adapter_accepts_different_input_after_short_pending_inner_writes() { + let (mut io, state) = scripted_adapter([ + ScriptStep::Write(2), + ScriptStep::Pending, + ScriptStep::Write(3), + ScriptStep::Pending, + ScriptStep::Write(usize::MAX), + ]); + io.set_override(custom_close()); + let original = masked_close([1, 3, 5, 7], 1000, "peer"); + let next = b"different-buffer"; + + assert_eq!(io.write(&original).await.unwrap(), original.len()); + io.write_all(next).await.unwrap(); + io.flush().await.unwrap(); + + let mut expected = masked_close([1, 3, 5, 7], 4001, "custom"); + expected.extend_from_slice(next); + assert_eq!(state.lock().unwrap().bytes, expected); + } + + #[tokio::test] + async fn close_ack_adapter_flush_drains_replacement_before_inner_flush() { + let (mut io, state) = scripted_adapter([ + ScriptStep::Write(1), + ScriptStep::Pending, + ScriptStep::Write(usize::MAX), + ]); + io.set_override(custom_close()); + let original = masked_close([2, 4, 6, 8], 1000, "peer"); + io.write_all(&original).await.unwrap(); + + io.flush().await.unwrap(); + + let state = state.lock().unwrap(); + assert_eq!(state.bytes, masked_close([2, 4, 6, 8], 4001, "custom")); + assert_eq!(state.events.last(), Some(&"flush")); + } + + #[tokio::test] + async fn close_ack_adapter_shutdown_drains_flushes_then_shuts_down() { + let (mut io, state) = scripted_adapter([ + ScriptStep::Write(2), + ScriptStep::Pending, + ScriptStep::Write(usize::MAX), + ]); + io.set_override(custom_close()); + let original = masked_close([8, 6, 4, 2], 1000, "peer"); + io.write_all(&original).await.unwrap(); + + io.shutdown().await.unwrap(); + + let state = state.lock().unwrap(); + assert_eq!(state.bytes, masked_close([8, 6, 4, 2], 4001, "custom")); + assert!(state.events.ends_with(&["flush", "shutdown"])); + } + + #[tokio::test] + async fn close_ack_adapter_propagates_write_error_without_duplicate_output() { + let (mut io, state) = scripted_adapter([ + ScriptStep::Write(3), + ScriptStep::Error(io::ErrorKind::BrokenPipe), + ScriptStep::Write(usize::MAX), + ]); + io.set_override(custom_close()); + let expected = masked_close([7, 7, 7, 7], 4001, "custom"); + io.write_all(&masked_close([7, 7, 7, 7], 1000, "peer")) + .await + .unwrap(); + + assert_eq!( + io.flush().await.unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + io.flush().await.unwrap(); + + assert_eq!(state.lock().unwrap().bytes, expected); + } + + #[tokio::test] + async fn close_ack_adapter_propagates_flush_and_shutdown_errors_without_rewriting() { + let (mut io, state) = scripted_adapter([]); + { + let mut state = state.lock().unwrap(); + state.flushes.extend([ + ScriptStep::Error(io::ErrorKind::Other), + ScriptStep::Write(0), + ]); + state.shutdowns.extend([ + ScriptStep::Error(io::ErrorKind::ConnectionReset), + ScriptStep::Write(0), + ]); + } + io.set_override(custom_close()); + let expected = masked_close([3, 3, 3, 3], 4001, "custom"); + io.write_all(&masked_close([3, 3, 3, 3], 1000, "peer")) + .await + .unwrap(); + + assert_eq!(io.flush().await.unwrap_err().kind(), io::ErrorKind::Other); + io.flush().await.unwrap(); + assert_eq!( + io.shutdown().await.unwrap_err().kind(), + io::ErrorKind::ConnectionReset + ); + io.shutdown().await.unwrap(); + + assert_eq!(state.lock().unwrap().bytes, expected); + } + + #[tokio::test] + async fn close_ack_adapter_rejects_invalid_target_frames_within_bound() { + for invalid in [ + vec![0x89, 0x80], + vec![0x08, 0x80], + vec![0x88, 0x00], + vec![0x88, 0xfe], + vec![0xc8, 0x80], + ] { + let (mut io, state) = scripted_adapter([]); + io.set_override(custom_close()); + assert_eq!( + io.write(&invalid).await.unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + assert!(state.lock().unwrap().bytes.is_empty()); + assert!(io.collected.len() <= CloseAckIo::MAX_CLOSE_FRAME_BYTES); + } + } + + #[tokio::test] + async fn close_ack_adapter_rejects_incomplete_target_on_flush_and_shutdown() { + for shutdown in [false, true] { + let (mut io, state) = scripted_adapter([]); + io.set_override(custom_close()); + assert_eq!(io.write(&[0x88]).await.unwrap(), 1); + let error = if shutdown { + io.shutdown().await.unwrap_err() + } else { + io.flush().await.unwrap_err() + }; + assert_eq!(error.kind(), io::ErrorKind::UnexpectedEof); + assert!(state.lock().unwrap().bytes.is_empty()); + } + } + + fn config() -> HttpConfig { + HttpConfig { + max_websocket_send_bytes: 5, + max_stream_total_bytes: 9, + ..HttpConfig::default() + } + } + + #[test] + fn send_and_total_limits_accept_exact_boundaries_and_reject_overflow() { + let config = config(); + assert_eq!(validate_send_size(&config, 2, 2, 5).unwrap(), 7); + assert!( + validate_send_size(&config, 2, 2, 6) + .unwrap_err() + .to_string() + .contains("send payload exceeds limit") + ); + assert!( + validate_send_size(&config, 3, 2, 5) + .unwrap_err() + .to_string() + .contains("total byte limit") + ); + } + + #[test] + fn control_payload_and_close_code_boundaries_are_explicit() { + let mut config = config(); + config.max_websocket_send_bytes = 126; + config.max_stream_total_bytes = 1024; + assert!(validate_control_payload(&config, &[0; 125]).is_ok()); + assert!( + validate_control_payload(&config, &[0; 126]) + .unwrap_err() + .to_string() + .contains("125 bytes") + ); + for code in [ + 1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014, 3000, 4999, + ] { + assert!(valid_close_code(code), "code {code} should be valid"); + } + for code in [0, 999, 1004, 1005, 1006, 1015, 1016, 2999, 5000, u16::MAX] { + assert!(!valid_close_code(code), "code {code} should be invalid"); + } + } + + #[test] + fn close_action_rejects_reserved_codes_before_creating_a_frame() { + for code in [1004, 1005, 1006, 1015] { + let action = VmMap::from_entries(vec![ + (Value::string("code"), Value::Int(code)), + (Value::string("reason"), Value::string("reserved")), + ]); + + assert!( + parse_close_action(&action) + .unwrap_err() + .to_string() + .contains("close code is invalid"), + "reserved code {code} must fail during callback action admission" + ); + } + } + + #[test] + fn application_byte_counter_is_checked_at_the_limit_and_on_overflow() { + assert_eq!(checked_application_counter(4, 5, 0, 9).unwrap(), 4); + assert!( + checked_application_counter(4, 5, 1, 9) + .unwrap_err() + .to_string() + .contains("total byte limit") + ); + assert!( + checked_application_counter(usize::MAX, 0, 1, usize::MAX) + .unwrap_err() + .to_string() + .contains("overflowed") + ); + assert!( + checked_application_counter(usize::MAX, 1, 0, usize::MAX) + .unwrap_err() + .to_string() + .contains("overflowed") + ); + } + + #[test] + fn close_deadline_starts_once_at_transition_and_is_bounded_by_call_deadline() { + let started = Instant::now(); + let call_deadline = started + Duration::from_millis(80); + let mut close_deadline = None; + + start_close_deadline( + &mut close_deadline, + started, + Duration::from_millis(100), + Some(call_deadline), + ) + .expect("close deadline should initialize"); + assert_eq!(close_deadline, Some(call_deadline)); + + start_close_deadline( + &mut close_deadline, + started + Duration::from_millis(20), + Duration::from_millis(5), + None, + ) + .expect("repeated close transition should be a no-op"); + assert_eq!(close_deadline, Some(call_deadline)); + } + + #[test] + fn wss_uses_http11_alpn_sni_original_host_and_pinned_address() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime should build"); + let certified = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) + .expect("certificate should generate"); + let cert_der = certified.cert.der().clone(); + let key_der = + rustls::pki_types::PrivateKeyDer::Pkcs8(certified.key_pair.serialize_der().into()); + let mut server_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der.clone()], key_der) + .expect("server TLS should configure"); + server_config.alpn_protocols = vec![b"http/1.1".to_vec()]; + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert_der).expect("certificate should be trusted"); + let client_config = Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + ); + let listener = runtime + .block_on(tokio::net::TcpListener::bind("127.0.0.1:0")) + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = runtime.spawn(async move { + let (stream, peer) = listener.accept().await.expect("client should connect"); + assert!(peer.ip().is_loopback()); + let tls = tokio_rustls::TlsAcceptor::from(Arc::new(server_config)) + .accept(stream) + .await + .expect("TLS should succeed"); + assert_eq!(tls.get_ref().1.alpn_protocol(), Some(b"http/1.1".as_slice())); + assert_eq!(tls.get_ref().1.server_name(), Some("localhost")); + #[allow(clippy::result_large_err)] + let callback = move |request: &tokio_tungstenite::tungstenite::handshake::server::Request, + response: tokio_tungstenite::tungstenite::handshake::server::Response| { + assert_eq!( + request.headers()["host"].to_str().unwrap(), + format!("localhost:{}", address.port()) + ); + Ok(response) + }; + tokio_tungstenite::accept_hdr_async(tls, callback) + .await + .expect("WebSocket handshake should succeed") + }); + let config = HttpConfig { + allowed_schemes: vec!["wss".to_string()], + allowed_hosts: vec!["localhost".to_string()], + allowed_ports: vec![address.port()], + allow_private_ips: true, + ..HttpConfig::default() + }; + let request_map = VmMap::from_entries(vec![( + Value::string("url"), + Value::string(format!("wss://localhost:{}/socket?q=1", address.port())), + )]); + let request = parse_websocket_request(&request_map, &config).expect("request should parse"); + let connected = runtime + .block_on(connect_socket_with_tls_config( + config, + request, + request_deadline(Duration::from_secs(2)).expect("deadline"), + Some(client_config), + )) + .expect("WSS should connect"); + assert_eq!(connected.status, 101); + drop(connected); + runtime.block_on(server).expect("server should complete"); + } +} diff --git a/src/builtins/runtime/mod.rs b/src/builtins/runtime/mod.rs index 96da098e..6ef039b8 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -31,6 +31,7 @@ pub(crate) mod core; pub(crate) mod error; pub(crate) mod event; mod host; +#[cfg(feature = "http-client")] mod http; mod io; #[cfg(target_arch = "wasm32")] @@ -46,14 +47,16 @@ pub(crate) mod resource; mod sqlite; mod typed; +#[cfg(feature = "http-client")] pub use http::{HttpConfig, HttpHostExt}; pub use io::{IoHostExt, IoPolicy}; #[cfg(feature = "sqlite")] pub use sqlite::{SqliteHostExt, SqliteLimits, SqlitePolicy}; pub use typed::HostCallResult; +#[allow(unused_imports)] use typed::{ AnyValue, IntoBuiltinCallOutcome, IntoHostCallOutcome, NumberValue, UnknownValue, VmArray, - VmBytes, VmMap, arg, borrow_arg, return_none, return_one, take_arg, + VmBytes, VmCallable, VmMap, arg, borrow_arg, return_none, return_one, take_arg, }; pub(crate) enum BuiltinCallOutcome { @@ -234,6 +237,7 @@ pub(crate) fn close_resources_by_type( } } +#[cfg(feature = "sqlite")] pub(crate) fn cancel_operations_by_owner( vm: &mut Vm, owner: OperationOwner, diff --git a/src/builtins/runtime/typed.rs b/src/builtins/runtime/typed.rs index 9162807a..3521b2b7 100644 --- a/src/builtins/runtime/typed.rs +++ b/src/builtins/runtime/typed.rs @@ -1,6 +1,7 @@ use super::BuiltinCallOutcome; pub(super) use crate::bytecode::{SharedArray, SharedBytes, SharedMap, VmMap}; use crate::vm::{CallOutcome, CallReturn, HostOpId, Value, VmError, VmResult}; +use std::marker::PhantomData; pub(super) type AnyValue = Value; pub(super) type UnknownValue = Value; @@ -20,6 +21,32 @@ pub(super) type VmBytesHandle = SharedBytes; #[allow(dead_code)] pub(super) type VmMapHandle = SharedMap; +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(super) struct VmCallable { + value: Value, + marker: PhantomData Signature>, +} + +impl VmCallable { + #[allow(dead_code)] + pub(super) fn into_value(self) -> Value { + self.value + } +} + +impl FromVmValue<'_> for VmCallable { + fn from_vm_value(value: &Value, _label: &str) -> VmResult { + if !matches!(value, Value::Callable(_)) { + return Err(VmError::TypeMismatch("callable")); + } + Ok(Self { + value: value.clone(), + marker: PhantomData, + }) + } +} + #[derive(Clone, Copy, Debug, PartialEq)] pub(super) enum NumberValue { Int(i64), diff --git a/src/compiler/typing/context.rs b/src/compiler/typing/context.rs index a373ef20..baa4b399 100644 --- a/src/compiler/typing/context.rs +++ b/src/compiler/typing/context.rs @@ -1977,6 +1977,34 @@ impl<'a> TypeContext<'a> { line_context: Option, source_name: Option<&str>, ) -> Result<(), CompileError> { + for (index, param) in signature.params.iter().enumerate() { + let crate::builtins::CallableParamType::Callable(callable) = param.ty else { + continue; + }; + let Some(arg) = args.get(index) else { + continue; + }; + let expected = crate::compiler::TypeSchema::Callable { + params: callable + .params + .iter() + .copied() + .map(callable_param_schema) + .collect(), + result: Box::new(callable_param_schema(*callable.return_type)), + }; + super::validate::validate_callable_expr_against_schema( + &format!("argument '{}'", param.name), + &expected, + arg, + state, + super::validate::DiagnosticSite { + line: line_context, + source_name, + }, + self, + )?; + } if matches!(signature.name.as_str(), "print" | "println") { if args .first() @@ -2210,6 +2238,32 @@ impl<'a> TypeContext<'a> { } } +fn callable_param_schema(param: crate::builtins::CallableParamType) -> crate::compiler::TypeSchema { + use crate::builtins::CallableParamType; + use crate::compiler::TypeSchema; + match param { + CallableParamType::Any => TypeSchema::Unknown, + CallableParamType::Null => TypeSchema::Null, + CallableParamType::Int => TypeSchema::Int, + CallableParamType::Float => TypeSchema::Float, + CallableParamType::Number => TypeSchema::Number, + CallableParamType::Bool => TypeSchema::Bool, + CallableParamType::String => TypeSchema::String, + CallableParamType::Bytes => TypeSchema::Bytes, + CallableParamType::Array => TypeSchema::Array(Box::new(TypeSchema::Unknown)), + CallableParamType::Map => TypeSchema::Map(Box::new(TypeSchema::Unknown)), + CallableParamType::Callable(signature) => TypeSchema::Callable { + params: signature + .params + .iter() + .copied() + .map(callable_param_schema) + .collect(), + result: Box::new(callable_param_schema(*signature.return_type)), + }, + } +} + fn merge_observed_function_param_schema( current: Option, next: Option, @@ -2636,6 +2690,73 @@ mod tests { use super::*; use crate::builtins::{CallableParam, CallableParamType}; + #[test] + fn generated_callable_float_schema_remains_distinct_from_number() { + assert_eq!( + callable_param_schema(CallableParamType::Float), + TypeSchema::Float + ); + assert_eq!( + callable_param_schema(CallableParamType::Number), + TypeSchema::Number + ); + } + + #[test] + fn generated_float_callable_metadata_rejects_non_float_callback_results() { + static FLOAT_PARAMS: &[CallableParamType] = &[CallableParamType::Float]; + static FLOAT_RESULT: CallableParamType = CallableParamType::Float; + let signature = HostCallableSignature { + name: "test::float_callback".to_string(), + params: vec![CallableParam { + name: "callback", + ty: CallableParamType::Callable(crate::builtins::CallableType { + params: FLOAT_PARAMS, + return_type: &FLOAT_RESULT, + }), + optional: false, + }], + runtime_builtin: true, + }; + let empty_impls = HashMap::new(); + let empty_decls = HashMap::new(); + let empty_structs = HashMap::new(); + let empty_names = HashMap::new(); + let empty_returns = HashMap::new(); + let empty_signatures = HashMap::new(); + let mut context = TypeContext::new( + &empty_impls, + &empty_decls, + &empty_structs, + &empty_names, + &empty_returns, + &empty_signatures, + TypingMode::StrictRustScript, + ); + let state = LocalTypeState::default(); + let wrong = [Expr::Closure(ClosureExpr { + param_slots: vec![0], + capture_copies: vec![], + body: Box::new(Expr::Int(1)), + })]; + let error = context + .validate_host_argument_types(&signature, &wrong, &state, None, None) + .expect_err("fn(float) -> float metadata must reject an int result"); + assert!( + error.to_string().contains("float") && error.to_string().contains("int"), + "unexpected compiler diagnostic: {error}" + ); + + let valid = [Expr::Closure(ClosureExpr { + param_slots: vec![0], + capture_copies: vec![], + body: Box::new(Expr::Float(1.0)), + })]; + context + .validate_host_argument_types(&signature, &valid, &state, None, None) + .expect("fn(float) -> float metadata must accept a float result"); + } + /// The authoritative `stream::emit` signature: one `any` payload. fn emit_signature(runtime_builtin: bool) -> HostCallableSignature { HostCallableSignature { diff --git a/src/compiler/typing/validate.rs b/src/compiler/typing/validate.rs index 6a2b61fe..df8489f9 100644 --- a/src/compiler/typing/validate.rs +++ b/src/compiler/typing/validate.rs @@ -223,7 +223,7 @@ fn validate_expr_matches_schema( ) } -fn validate_callable_expr_against_schema( +pub(super) fn validate_callable_expr_against_schema( label: &str, expected_schema: &TypeSchema, expr: &Expr, @@ -424,6 +424,7 @@ fn param_accepts_bound_type(expected: CallableParamType, actual: BoundType, stri } CallableParamType::Map => matches!(actual, BoundType::Map | BoundType::MapOf(_)), CallableParamType::Number => is_numeric_bound_type(actual), + CallableParamType::Callable(_) => actual == BoundType::Callable, } } @@ -440,9 +441,9 @@ fn format_param_types(params: &[CallableParam]) -> String { .iter() .map(|param| { if param.optional { - format!("{}?: {}", param.name, param.ty.label()) + format!("{}?: {}", param.name, param.ty.display_label()) } else { - format!("{}: {}", param.name, param.ty.label()) + format!("{}: {}", param.name, param.ty.display_label()) } }) .collect::>() diff --git a/src/lib.rs b/src/lib.rs index 854a5edc..f61a915a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,13 +25,15 @@ pub use assembler::{AsmParseError, Assembler, AssemblerError, BytecodeBuilder, a pub use builtins::runtime::HostCallResult; #[cfg(feature = "runtime")] pub use builtins::runtime::print::{PrintHostFunction, PrintlnHostFunction, format_value}; +#[cfg(feature = "http-client")] +pub use builtins::runtime::{HttpConfig, HttpHostExt}; #[cfg(feature = "runtime")] -pub use builtins::runtime::{HttpConfig, HttpHostExt, IoHostExt, IoPolicy}; +pub use builtins::runtime::{IoHostExt, IoPolicy}; #[cfg(feature = "sqlite")] pub use builtins::runtime::{SqliteHostExt, SqliteLimits, SqlitePolicy}; pub use builtins::{ BUILTIN_CATALOG, BuiltinFunction, BuiltinNamespaceMemberSpec, BuiltinNamespaceSpec, - CallableDef, CallableParam, CallableParamType, CallableSignature, HostExecution, + CallableDef, CallableParam, CallableParamType, CallableSignature, CallableType, HostExecution, LanguageBuiltinSpec, builtin_namespace_specs, callable_signatures_for_builtin_namespace_member, default_host_callables, is_builtin_namespace, language_builtin_specs, resolve_builtin_namespace_call, @@ -92,10 +94,10 @@ pub use vm::{ CapabilityProfileBuilder, DEFAULT_MAX_SCRIPT_CALL_DEPTH, EpochCheckpoint, EpochHandle, FuelCheckpoint, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, HostFunctionRegistry, HostFuture, HostFutureOutput, HostOpId, HostStackFunction, - IntoScriptValue, Invocation, InvocationError, InvocationItem, InvocationPoll, - QueuedScriptInvocation, ScriptArgs, ScriptCallback, ScriptResult, StaticHostArgsFunction, - StaticHostFunction, StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, - VmYieldReason, + HostStreamAction, HostStreamDriver, HostStreamPoll, IntoScriptValue, Invocation, + InvocationError, InvocationItem, InvocationPoll, QueuedScriptInvocation, ScriptArgs, + ScriptCallback, ScriptResult, StaticHostArgsFunction, StaticHostFunction, + StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, VmYieldReason, }; #[cfg(feature = "runtime")] diff --git a/src/vm/async_host/mod.rs b/src/vm/async_host/mod.rs index 68ec8e80..e0669c78 100644 --- a/src/vm/async_host/mod.rs +++ b/src/vm/async_host/mod.rs @@ -5,6 +5,9 @@ use std::task::{Context, Poll, Wake, Waker}; use super::*; +pub(crate) mod stream; +pub use stream::{HostStreamAction, HostStreamDriver, HostStreamPoll}; + type HostVmCompletion = Box VmResult + Send + 'static>; pub enum HostFutureOutput { @@ -148,6 +151,10 @@ impl Vm { let Some(waiting) = self.instance.waiting_host_op.take() else { return; }; + if self.host.stream_drivers.contains_key(&waiting.op_id) { + self.cancel_callable_stream(); + return; + } let Ok(operation_id) = crate::builtins::runtime::cancellation::OperationId::from_raw(waiting.op_id) else { @@ -214,6 +221,9 @@ impl Vm { let Some(waiting) = self.instance.waiting_host_op else { return Poll::Ready(Ok(())); }; + if self.host.stream_drivers.contains_key(&waiting.op_id) { + return self.poll_callable_stream(waiting.op_id, cx); + } let operation_id = match crate::builtins::runtime::cancellation::OperationId::from_raw(waiting.op_id) { Ok(operation_id) => operation_id, diff --git a/src/vm/async_host/stream.rs b/src/vm/async_host/stream.rs new file mode 100644 index 00000000..1f658964 --- /dev/null +++ b/src/vm/async_host/stream.rs @@ -0,0 +1,351 @@ +use std::task::{Context, Poll}; + +use crate::compiler::TypeSchema; +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. +#[derive(Debug)] +pub 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. +#[derive(Debug)] +pub enum HostStreamAction { + /// Continue by returning control to producer polling. + Continue, + /// Finish the stream and return the supplied summary to the script call. + Complete(Value), +} + +/// 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. +pub 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; +} + +#[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. + pub fn submit_callable_stream( + &mut self, + callback: Value, + driver: impl HostStreamDriver, + ) -> VmResult { + self.validate_stream_callback_value(&callback)?; + if self.instance.host_stream.is_some() { + return Err(VmError::HostError( + "vm already owns an active callable stream".to_string(), + )); + } + 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)) + } + + 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 record_callable_stream_resume_ip(&mut self, op_id: HostOpId, resume_ip: usize) { + if let Some(stream) = self.instance.host_stream.as_mut() + && stream.op_id == op_id + { + stream.parent_ip = resume_ip; + } + } + + pub(crate) fn cancel_callable_stream(&mut self) { + if let Some(stream) = self.instance.host_stream.take() { + self.host.stream_drivers.remove(&stream.op_id); + if let Some(item) = stream.item { + self.drop_value_with_contract(item); + } + self.drop_value_with_contract(stream.callback); + } + } + + 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)) => { + self.abort_callable_stream(); + Poll::Ready(Err(error)) + } + Poll::Ready(Ok(HostStreamPoll::Complete(summary))) => { + self.finish_callable_stream(summary); + Poll::Ready(Ok(())) + } + 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) => { + self.abort_callable_stream(); + Poll::Ready(Err(error)) + } + } + } + } + } + + 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) { + if self + .instance + .host_stream + .as_ref() + .is_some_and(|stream| stream.phase == HostStreamPhase::RunCallback) + { + self.abort_callable_stream(); + } + } + + fn finish_callable_stream_callback(&mut self) -> VmResult { + let Some(action) = self.instance.host_return.take() else { + self.abort_callable_stream(); + return Err(VmError::InvalidFrameState( + "callable stream callback returned no action", + )); + }; + 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(stream) = self.instance.host_stream.as_mut() { + stream.phase = HostStreamPhase::AwaitItem; + } + self.instance.waiting_host_op = Some(super::WaitingHostOp { op_id }); + Ok(VmStatus::Waiting(op_id)) + } + Ok(HostStreamAction::Complete(summary)) => { + self.finish_callable_stream(summary); + Ok(VmStatus::Halted) + } + Err(error) => { + self.abort_callable_stream(); + Err(error) + } + } + } + + fn finish_callable_stream(&mut self, summary: Value) { + let Some(stream) = self.instance.host_stream.take() else { + return; + }; + self.host.stream_drivers.remove(&stream.op_id); + 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); + } + self.instance.stack.push(summary); + } + + fn abort_callable_stream(&mut self) { + let Some(stream) = self.instance.host_stream.take() else { + return; + }; + self.host.stream_drivers.remove(&stream.op_id); + 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); + } + } +} diff --git a/src/vm/host.rs b/src/vm/host.rs index 775a3b2a..83e405be 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -1717,7 +1717,12 @@ impl Vm { saved_stack.append(&mut host_stack); self.instance.stack = saved_stack; let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_bound_host_op(resolved_index, op_id)?; + self.record_callable_stream_resume_ip(op_id, resume_ip); + if self.host.stream_drivers.contains_key(&op_id) { + self.set_waiting_operation(op_id)?; + } else { + self.set_waiting_bound_host_op(resolved_index, op_id)?; + } self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } @@ -1832,7 +1837,12 @@ impl Vm { CallOutcome::Pending(op_id) => { self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_bound_host_op(resolved_index, op_id)?; + self.record_callable_stream_resume_ip(op_id, resume_ip); + if self.host.stream_drivers.contains_key(&op_id) { + self.set_waiting_operation(op_id)?; + } else { + self.set_waiting_bound_host_op(resolved_index, op_id)?; + } self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } @@ -1893,7 +1903,12 @@ impl Vm { CallOutcome::Pending(op_id) => { self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_bound_host_op(resolved_index, op_id)?; + self.record_callable_stream_resume_ip(op_id, resume_ip); + if self.host.stream_drivers.contains_key(&op_id) { + self.set_waiting_operation(op_id)?; + } else { + self.set_waiting_bound_host_op(resolved_index, op_id)?; + } self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } diff --git a/src/vm/host_runtime.rs b/src/vm/host_runtime.rs index 49b583ae..5cb9643c 100644 --- a/src/vm/host_runtime.rs +++ b/src/vm/host_runtime.rs @@ -20,7 +20,7 @@ use crate::builtins::runtime::cancellation::{ }; use crate::builtins::runtime::resource::{DEFAULT_MAX_RESOURCES, ResourceArena}; -use crate::vm::async_host::HostAsyncBridge; +use crate::vm::async_host::{HostAsyncBridge, HostStreamDriver}; use crate::vm::host::VmHostFunction; /// Embedder-supplied print sink for `print`/`debug` output. @@ -49,6 +49,7 @@ pub(crate) struct HostRuntime { host_function_states: HashMap>, pub(crate) async_bridge: Option>, pub(crate) submitted_host_ops: HashSet, + pub(crate) stream_drivers: HashMap>, pub(crate) runtime_print_sink: Option>, } @@ -75,6 +76,7 @@ impl HostRuntime { host_function_states: HashMap::new(), async_bridge: None, submitted_host_ops: HashSet::new(), + stream_drivers: HashMap::new(), runtime_print_sink: None, } } @@ -91,6 +93,7 @@ impl HostRuntime { .runtime_resources .close_all(CancellationReason::VmReset); self.submitted_host_ops.clear(); + self.stream_drivers.clear(); } pub(crate) fn set_host_function_state(&mut self, state: T) @@ -110,6 +113,7 @@ impl HostRuntime { .downcast_ref() } + #[cfg(feature = "http-client")] pub(crate) fn host_function_state_mut(&mut self) -> Option<&mut T> where T: Any + Send, diff --git a/src/vm/instance.rs b/src/vm/instance.rs index d74cbad0..2845dcb5 100644 --- a/src/vm/instance.rs +++ b/src/vm/instance.rs @@ -19,6 +19,7 @@ use std::sync::{Arc, Weak}; use crate::bytecode::{CallableValue, Program, SharedCaptureCell, Value}; use crate::vm::async_host::WaitingHostOp; +use crate::vm::async_host::stream::HostStreamContinuation; use crate::vm::invocation::{InvocationPhase, InvocationState}; use crate::vm::map_iter::MapIteratorState; use crate::vm::{DEFAULT_MAX_SCRIPT_CALL_DEPTH, VmYieldReason}; @@ -84,6 +85,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>>, @@ -121,6 +123,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(), @@ -164,6 +167,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(); diff --git a/src/vm/invocation.rs b/src/vm/invocation.rs index 408fd095..a68bef06 100644 --- a/src/vm/invocation.rs +++ b/src/vm/invocation.rs @@ -92,9 +92,9 @@ pub(crate) enum InvocationPhase { /// One active invocation handle borrowing the VM. /// -/// Polling drives execution; dropping the handle abandons the invocation but -/// keeps it active on the VM until it fuses (a new invocation is rejected while -/// one is active). +/// Polling drives execution. Dropping a handle that has not fused retires its +/// invocation synchronously, including any waiting host operation or callable +/// stream, so the VM can be reused immediately. pub struct Invocation<'vm> { vm: &'vm mut Vm, } @@ -126,10 +126,25 @@ impl Invocation<'_> { pub fn cancel(&mut self, reason: CancellationReason) -> VmResult<()> { let cancellation_result = self.vm.run_ctx.cancel(reason); self.vm.cancel_waiting_host_op_with_reason(reason); + self.vm.cancel_callable_stream(); cancellation_result } } +impl Drop for Invocation<'_> { + fn drop(&mut self) { + let active = self + .vm + .instance + .invocation + .as_ref() + .is_some_and(|state| !matches!(state.phase, InvocationPhase::Fused)); + if active { + self.vm.release_invocation(); + } + } +} + /// One poll step selected from the current invocation phase. enum InvocationAction { Cancelled, @@ -472,12 +487,16 @@ impl Vm { .as_ref() .map(|state| (state.stack_base, state.frame_count)) .unwrap_or((0, 0)); + // Retire the currently awaited operation before unwinding its frames. + // `Requested` is the embedding-owned cancellation reason used when a + // consumer abandons a handle. For callable-stream producer waits this + // also removes the driver; for callback waits it cancels the nested op. + self.cancel_waiting_host_op_with_reason(CancellationReason::Requested); + self.cancel_callable_stream(); self.abort_host_invocation(stack_base, frame_count); - if let Some(state) = self.instance.invocation.as_mut() { - state.phase = InvocationPhase::Fused; - state.emit_yield_pending = false; - state.pending_error = None; - } + // Pending Event/Complete values must follow the VM drop contract even + // when their terminal item can no longer be observed. + self.instance.drop_invocation_state(); self.run_ctx.cancellation = CancellationToken::root(); } diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 4acd749c..72d3c286 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -26,7 +26,8 @@ mod tests; pub use self::aot::AotArtifactError; pub use self::async_host::{ - CaptureAsyncHostContext, HostAsyncBridge, HostFuture, HostFutureOutput, + CaptureAsyncHostContext, HostAsyncBridge, HostFuture, HostFutureOutput, HostStreamAction, + HostStreamDriver, HostStreamPoll, }; pub use self::capability::{CapabilityProfile, CapabilityProfileBuilder}; use self::engine::Engine; @@ -703,6 +704,7 @@ impl Vm { self.cancel_waiting_host_op_with_reason( crate::builtins::runtime::cancellation::CancellationReason::VmReset, ); + self.cancel_callable_stream(); self.host.reset_for_reuse(); self.run_ctx.reset_for_reuse(); self.instance.reset(&self.program); @@ -984,14 +986,28 @@ impl Vm { } pub fn run(&mut self) -> VmResult { - self.run_internal(None, true) + let status = match self.run_internal(None, true) { + Ok(status) => status, + Err(error) => { + self.abort_callable_stream_on_run_error(); + return Err(error); + } + }; + self.resume_callable_stream_after_run(status) } pub fn run_with_debugger( &mut self, debugger: &mut crate::debugger::Debugger, ) -> VmResult { - self.run_internal(Some(debugger), false) + let status = match self.run_internal(Some(debugger), false) { + Ok(status) => status, + Err(error) => { + self.abort_callable_stream_on_run_error(); + return Err(error); + } + }; + self.resume_callable_stream_after_run(status) } } @@ -1000,6 +1016,7 @@ impl Drop for Vm { self.cancel_waiting_host_op_with_reason( crate::builtins::runtime::cancellation::CancellationReason::VmReset, ); + self.cancel_callable_stream(); self.host.reset_for_reuse(); self.instance.drop_cleanup(); } @@ -2733,7 +2750,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) => { + self.abort_callable_stream_on_run_error(); + return Err(error); + } + }; + self.resume_callable_stream_after_run(status) } pub fn stack(&self) -> &[Value] { @@ -2900,6 +2924,7 @@ impl Vm { pub fn shutdown(&mut self) { self.invalidate_callback_registries(); self.cancel_waiting_host_op(); + self.cancel_callable_stream(); self.instance.queued_callables.clear(); self.instance.completed_callable_results.clear(); self.instance.owned_callables.clear(); diff --git a/src/vm/tests.rs b/src/vm/tests.rs index 94690272..867335f4 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -436,7 +436,7 @@ fn deadline_cancellation_closes_operation_payload_before_registry_removal() { .host .runtime_operations .start_owned( - OperationOwner::Http, + OperationOwner::Io, Some(&vm.run_ctx.cancellation), Some(Instant::now() - Duration::from_millis(1)), None, @@ -486,7 +486,7 @@ fn worker_observed_deadline_retains_payload_until_vm_consumes_operation() { .host .runtime_operations .start_owned( - OperationOwner::Http, + OperationOwner::Io, Some(&vm.run_ctx.cancellation), Some(Instant::now() - Duration::from_millis(1)), None, @@ -2537,12 +2537,10 @@ fn run_yields_before_ret_in_call_ret_sequence_when_epoch_deadline_is_reached() { } #[test] -fn pre_cancelled_invocation_stays_pending_until_error_delivery() { - // Regression: starting an invocation on an already-cancelled run context - // must not release early (no callable, frame, or host operation has - // started yet). The reason stays pending on the run context until normal - // error delivery consumes the typed error item, which releases exactly - // once. +fn dropping_pre_cancelled_invocation_consumes_cancellation_at_the_boundary() { + // Dropping an invocation with a pending typed cancellation retires that + // invocation without manufacturing an unobservable terminal item. The VM + // cancellation root is refreshed for immediate reuse. let compiled = crate::compile_source( r#" pub fn run() -> int { @@ -2562,15 +2560,21 @@ fn pre_cancelled_invocation_stays_pending_until_error_delivery() { .expect("exported run callable should resolve"); { let _invocation = vm - .start_invocation(callable, vec![]) + .start_invocation(callable.clone(), vec![]) .expect("invocation should start"); - // Dropping the handle abandons the invocation but keeps it active on - // the VM; the pre-cancelled transition must not have released it. } assert!( - vm.run_ctx.cancellation.reason().is_some(), - "the pre-cancellation must remain pending until the error item is consumed" + vm.run_ctx.cancellation.reason().is_none(), + "dropping the invocation must consume its pending cancellation" ); + + let mut replacement = vm + .start_invocation(callable, vec![]) + .expect("the vm should be reusable after the dropped invocation"); + assert!(matches!( + replacement.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) + )); } #[test] diff --git a/tests/compiler/compiler_rustscript_tests.rs b/tests/compiler/compiler_rustscript_tests.rs index 7c230c34..4d3c0d5d 100644 --- a/tests/compiler/compiler_rustscript_tests.rs +++ b/tests/compiler/compiler_rustscript_tests.rs @@ -3635,6 +3635,46 @@ fn rustscript_explicit_optional_type_annotations_work() { expected_kind: SourceErrorKind::Compile(CompileErrorKind::CallableArgumentTypeMismatch), expected_contains_all: &["callable body result expects 'int'", "got bool"], }, + SourceErrorCase { + name: "typed host callable parameters reject wrong closure arity", + source: r#" + fn stream(handler: fn(map) -> map) -> map; + stream(|value, extra| value); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Compile(CompileErrorKind::CallableArgumentTypeMismatch), + expected_contains_all: &[ + "argument 'handler'", + "fn(map) -> map", + "takes 2 parameters", + ], + }, + SourceErrorCase { + name: "typed host callable parameters reject wrong closure parameter type", + source: r#" + fn stream(handler: fn(map) -> map) -> map; + fn handle(value: int) -> map { { action: "continue" } } + stream(handle); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Compile(CompileErrorKind::CallableArgumentTypeMismatch), + expected_contains_all: &[ + "argument 'handler' type mismatch", + "arg[0]", + "map", + "int", + ], + }, + SourceErrorCase { + name: "typed host callable parameters reject wrong closure return type", + source: r#" + fn stream(handler: fn(map) -> map) -> map; + stream(|value| 1); + "#, + flavor: SourceFlavor::RustScript, + expected_kind: SourceErrorKind::Compile(CompileErrorKind::CallableArgumentTypeMismatch), + expected_contains_all: &["callable body result type mismatch", "map", "int"], + }, SourceErrorCase { name: "json encode rejects bytes under strict rustscript typing", source: r#" diff --git a/tests/host_binding_generation_tests.rs b/tests/host_binding_generation_tests.rs index 2ce73ef4..73fde60a 100644 --- a/tests/host_binding_generation_tests.rs +++ b/tests/host_binding_generation_tests.rs @@ -3,13 +3,16 @@ mod build_script; use build_script::{ - HostBindingKind, HostExecutionKind, classify_host_binding, infer_host_execution, + HostBindingKind, HostExecutionKind, callable_param_expr, classify_host_binding, + infer_host_execution, type_label, }; use syn::parse_quote; use vm::{ BuiltinFunction, CapabilityProfile, HostFunctionRegistry, JitConfig, JitTraceTerminal, Value, Vm, VmStatus, compile_source, }; +#[cfg(feature = "http-client")] +use vm::{HostExecution, default_host_callables}; fn native_jit_supported() -> bool { (cfg!(target_arch = "x86_64") @@ -18,6 +21,23 @@ fn native_jit_supported() -> bool { && (cfg!(target_os = "linux") || cfg!(target_os = "macos"))) } +#[test] +fn preserves_typed_callable_host_parameter_schema() { + let ty: syn::Type = parse_quote!(VmCallable VmMap>); + assert_eq!(type_label(&ty), "fn(map) -> map"); + assert_eq!( + callable_param_expr("fn(map) -> map"), + "CallableParamType::Callable(CallableType { params: &[CallableParamType::Map], return_type: &CallableParamType::Map })" + ); + + let float_ty: syn::Type = parse_quote!(VmCallable f64>); + assert_eq!(type_label(&float_ty), "fn(float) -> float"); + assert_eq!( + callable_param_expr("fn(float) -> float"), + "CallableParamType::Callable(CallableType { params: &[CallableParamType::Float], return_type: &CallableParamType::Float })" + ); +} + #[test] fn classifies_best_effort_host_bindings_from_signatures() { for function in [ @@ -284,6 +304,74 @@ fn runtime_exit_still_halts_for_direct_and_cached_default_bindings() { } } +#[cfg(feature = "http-client")] +#[test] +fn generated_http_imports_are_unique_typed_and_independently_capability_gated() { + const IMPORTS: [&str; 3] = [ + "http::client::request", + "http::client::sse", + "http::client::websocket", + ]; + let callables = default_host_callables(); + for name in IMPORTS { + let discovered = callables + .iter() + .filter(|callable| callable.name == name) + .collect::>(); + assert_eq!(discovered.len(), 1, "{name} discovery count"); + let callable = discovered[0]; + assert_eq!(callable.signature.return_type, "map"); + if name == "http::client::request" { + assert_eq!(callable.signature.params.len(), 1); + assert_eq!(callable.signature.params[0].ty.display_label(), "map"); + } else { + assert_eq!(callable.signature.params.len(), 2); + assert_eq!(callable.signature.params[0].ty.display_label(), "map"); + assert_eq!( + callable.signature.params[1].ty.display_label(), + "fn(map) -> map" + ); + assert_eq!(callable.host_execution, HostExecution::MaySuspend); + } + } + + for mask in 0_u8..8 { + let mut builder = CapabilityProfile::builder(); + for (index, name) in IMPORTS.iter().enumerate() { + if mask & (1 << index) != 0 { + builder = builder.allow_host_import(*name); + } + } + let profile = builder.build(); + for (index, name) in IMPORTS.iter().enumerate() { + assert_eq!( + profile.allows_host_import(name), + mask & (1 << index) != 0, + "mask {mask:03b}, import {name}" + ); + } + + let source = r#" + use http; + fn callback(item: map) -> map { { action: "stop" } } + http::client::request({ url: "https://example.test/" }); + http::client::sse({ url: "https://example.test/" }, callback); + http::client::websocket({ url: "wss://example.test/" }, callback); + "#; + let compiled = compile_source(source).expect("HTTP imports should compile"); + let mut vm = Vm::new(compiled.program); + let mut registry = HostFunctionRegistry::new(); + registry.set_capability_profile(profile); + let result = registry.bind_vm_cached(&mut vm); + if mask == 0b111 { + result.expect("all three explicit capabilities should bind"); + } else { + let error = result.expect_err("a missing HTTP capability must reject binding"); + assert!(error.to_string().contains("capability profile"), "{error}"); + } + } +} + #[test] fn capability_profile_fingerprint_uses_stable_callable_identities() { let first = CapabilityProfile::builder() diff --git a/tests/http_feature_gating_tests.rs b/tests/http_feature_gating_tests.rs new file mode 100644 index 00000000..a1248f7d --- /dev/null +++ b/tests/http_feature_gating_tests.rs @@ -0,0 +1,36 @@ +#[test] +fn http_callables_follow_the_http_client_feature_gate() { + for name in [ + "http::client::request", + "http::client::sse", + "http::client::websocket", + ] { + let published = vm::default_host_callables() + .iter() + .any(|callable| callable.name == name); + assert_eq!(published, cfg!(feature = "http-client"), "{name}"); + } +} + +#[cfg(feature = "http-client")] +#[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/invocation_stream_tests.rs b/tests/invocation_stream_tests.rs index f5ea5a29..d060724f 100644 --- a/tests/invocation_stream_tests.rs +++ b/tests/invocation_stream_tests.rs @@ -109,6 +109,7 @@ fn invocation_without_events_yields_complete_then_fused_end() { ), "the stream must stay fused after Complete" ); + drop(invocation); // Once the first invocation has fused, a new invocation may start on the // same VM. @@ -126,7 +127,7 @@ fn invocation_without_events_yields_complete_then_fused_end() { } #[test] -fn invocation_starting_a_second_invocation_while_one_is_active_is_rejected() { +fn dropping_an_unpolled_invocation_allows_a_second_invocation() { let mut vm = compiled_vm( r#" pub fn run() -> int { @@ -138,21 +139,19 @@ fn invocation_starting_a_second_invocation_while_one_is_active_is_rejected() { .resolve_exported_callable("run") .expect("exported run callable should resolve"); { - // The handle is dropped without polling; the invocation stays active - // on the VM until it fuses. + // Dropping the handle retires even a CompletePending invocation whose + // terminal item was never observed. let _invocation = vm .start_invocation(callable.clone(), vec![]) .expect("first invocation should start"); } - // Dropping the handle keeps the invocation active on the VM; starting a - // second invocation must be rejected while the first one has not fused. - let rejected = vm + let mut second = vm .start_invocation(callable, vec![]) - .expect_err("a second active invocation must be rejected"); - assert!( - matches!(rejected, VmError::InvalidFrameState(_)), - "unexpected rejection error: {rejected:?}" - ); + .expect("dropping the first handle must release the vm immediately"); + assert!(matches!( + second.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) + )); } #[test] @@ -642,6 +641,7 @@ fn invocation_cancellation_is_consumed_at_the_invocation_boundary() { invocation.poll_next().expect("poll should succeed"), InvocationPoll::Ready(None) )); + drop(invocation); // A fresh invocation on the same VM must not inherit the old reason: it // runs to completion instead of being cancelled on arrival. @@ -713,6 +713,7 @@ fn invocation_cancel_during_event_pending_discards_the_pending_event() { invocation.poll_next().expect("poll should succeed"), InvocationPoll::Ready(None) )); + drop(invocation); // The discarded event payload (map plus its two key/value pairs) must be // dropped through the VM drop-contract path, not leaked. diff --git a/tests/runtime_context_tests.rs b/tests/runtime_context_tests.rs index 2c2a05f3..958ef2c0 100644 --- a/tests/runtime_context_tests.rs +++ b/tests/runtime_context_tests.rs @@ -320,7 +320,7 @@ fn concurrent_completion_and_cancellation_choose_one_terminal_state() { fn completed_child_ignores_later_parent_cancellation() { let mut registry = OperationRegistry::with_limit(4).expect("operation limit should be valid"); let parent = registry - .start_owned(cancellation::OperationOwner::Http, None, None, None) + .start_owned(cancellation::OperationOwner::Io, None, None, None) .expect("parent should start"); let child = registry .start_owned( diff --git a/tests/vm/host_stream_callback_tests.rs b/tests/vm/host_stream_callback_tests.rs new file mode 100644 index 00000000..9749fada --- /dev/null +++ b/tests/vm/host_stream_callback_tests.rs @@ -0,0 +1,1124 @@ +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Wake, Waker}; + +use vm::{ + CallOutcome, CallReturn, CancellationReason, HostAsyncBridge, HostFunction, HostFuture, + HostFutureOutput, HostOpId, HostStreamAction, HostStreamDriver, HostStreamPoll, + InvocationError, InvocationPoll, JitConfig, Value, Vm, VmError, VmMap, VmResult, VmStatus, + compile_source, +}; + +fn map(entries: impl IntoIterator) -> Value { + Value::Map(Arc::new(VmMap::from_entries( + entries + .into_iter() + .map(|(key, value)| (string(key), value)) + .collect(), + ))) +} + +fn string(value: &str) -> Value { + Value::String(Arc::new(value.to_string())) +} + +fn map_field<'a>(value: &'a Value, name: &str) -> Option<&'a Value> { + let Value::Map(entries) = value else { + return None; + }; + entries.get(&string(name)) +} + +#[derive(Default)] +struct NoopWake; + +impl Wake for NoopWake { + fn wake(self: Arc) {} +} + +fn context() -> Context<'static> { + let waker = Waker::from(Arc::new(NoopWake)); + Context::from_waker(Box::leak(Box::new(waker))) +} + +#[derive(Default)] +struct CountingWake(AtomicUsize); + +impl Wake for CountingWake { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } + + fn wake_by_ref(self: &Arc) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +struct SyntheticDriver { + items: VecDeque, + polls: Arc, + applied: Arc, + stopped: Arc, + producer_error: bool, +} + +impl Drop for SyntheticDriver { + fn drop(&mut self) { + self.stopped.fetch_add(1, Ordering::SeqCst); + } +} + +impl HostStreamDriver for SyntheticDriver { + fn poll_next(&mut self, _cx: &mut Context<'_>) -> Poll> { + self.polls.fetch_add(1, Ordering::SeqCst); + if self.producer_error { + return Poll::Ready(Err(VmError::HostError( + "synthetic producer failed".to_string(), + ))); + } + match self.items.pop_front() { + Some(item) => Poll::Ready(Ok(HostStreamPoll::Item(item))), + None => Poll::Ready(Ok(HostStreamPoll::Complete(map([ + ("outcome", string("eof")), + ( + "items", + Value::Int(self.applied.load(Ordering::SeqCst) as i64), + ), + ])))), + } + } + + fn apply_action(&mut self, action: Value) -> Result { + let Some(Value::String(action)) = map_field(&action, "action") else { + return Err(VmError::HostError( + "stream callback action must be a map with string 'action'".to_string(), + )); + }; + self.applied.fetch_add(1, Ordering::SeqCst); + match action.as_str() { + "continue" => Ok(HostStreamAction::Continue), + "stop" => Ok(HostStreamAction::Complete(map([ + ("outcome", string("stopped")), + ( + "items", + Value::Int(self.applied.load(Ordering::SeqCst) as i64), + ), + ]))), + other => Err(VmError::HostError(format!( + "invalid synthetic stream action '{other}'" + ))), + } + } +} + +struct DropOnlyDriver { + stopped: Arc, +} + +impl Drop for DropOnlyDriver { + fn drop(&mut self) { + self.stopped.fetch_add(1, Ordering::SeqCst); + } +} + +impl HostStreamDriver for DropOnlyDriver { + fn poll_next(&mut self, _cx: &mut Context<'_>) -> Poll> { + panic!("rejected driver must never be polled") + } + + fn apply_action(&mut self, _action: Value) -> VmResult { + panic!("rejected driver must never receive an action") + } +} + +struct PendingProducerDriver { + polls: Arc, + applied: Arc, + stopped: Arc, +} + +impl Drop for PendingProducerDriver { + fn drop(&mut self) { + self.stopped.fetch_add(1, Ordering::SeqCst); + } +} + +impl HostStreamDriver for PendingProducerDriver { + fn poll_next(&mut self, _cx: &mut Context<'_>) -> Poll> { + self.polls.fetch_add(1, Ordering::SeqCst); + Poll::Pending + } + + fn apply_action(&mut self, _action: Value) -> VmResult { + self.applied.fetch_add(1, Ordering::SeqCst); + panic!("a pending producer must never receive a callback action") + } +} + +struct PendingProducerHost { + polls: Arc, + applied: Arc, + stopped: Arc, + op_id: Arc, +} + +impl HostFunction for PendingProducerHost { + fn call(&mut self, vm: &mut Vm, args: &[Value]) -> VmResult { + let [callback] = args else { + return Err(VmError::HostError("expected one callback".to_string())); + }; + let outcome = vm.submit_callable_stream( + callback.clone(), + PendingProducerDriver { + polls: Arc::clone(&self.polls), + applied: Arc::clone(&self.applied), + stopped: Arc::clone(&self.stopped), + }, + )?; + if let CallOutcome::Pending(op_id) = outcome { + self.op_id.store(op_id as usize, Ordering::SeqCst); + } + Ok(outcome) + } +} + +struct SyntheticStreamHost { + polls: Arc, + applied: Arc, + stopped: Arc, + invalid_first: bool, + producer_error: bool, +} + +struct YieldOnceHost(bool); + +impl HostFunction for YieldOnceHost { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> Result { + if !self.0 { + self.0 = true; + Ok(CallOutcome::Yield) + } else { + Ok(CallOutcome::Return(vec![Value::Null].into())) + } + } +} + +struct YieldForeverHost(Arc); + +impl HostFunction for YieldForeverHost { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(CallOutcome::Yield) + } +} + +struct WaitHost; + +impl HostFunction for WaitHost { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> Result { + vm.submit_host_future(Box::pin(std::future::pending())) + } +} + +struct RuntimeExitHost; + +impl HostFunction for RuntimeExitHost { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> Result { + Ok(CallOutcome::Halt) + } +} + +#[derive(Default)] +struct PendingBridge { + futures: HashMap, +} + +impl HostAsyncBridge for PendingBridge { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + self.futures.insert(op_id, future); + Ok(()) + } + + fn poll_op(&mut self, op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(VmError::HostError(format!( + "unknown host operation {op_id}" + )))) + } + + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + self.futures.get_mut(&op_id).map_or( + 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.futures.remove(&op_id); + } +} + +#[derive(Default)] +struct CancellationLog { + pending: HashMap, + cancellations: Vec<(HostOpId, CancellationReason)>, +} + +struct RecordingPendingBridge(Arc>); + +impl HostAsyncBridge for RecordingPendingBridge { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + self.0 + .lock() + .expect("cancellation log lock") + .pending + .insert(op_id, future); + Ok(()) + } + + fn poll_op(&mut self, op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(VmError::HostError(format!( + "unknown host operation {op_id}" + )))) + } + + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + _cx: &mut Context<'_>, + ) -> Poll> { + if self + .0 + .lock() + .expect("cancellation log lock") + .pending + .contains_key(&op_id) + { + Poll::Pending + } else { + Poll::Ready(Err(VmError::HostError(format!( + "unknown submitted host operation {op_id}" + )))) + } + } + + fn cancel_op_with_reason(&mut self, op_id: HostOpId, reason: CancellationReason) { + let mut log = self.0.lock().expect("cancellation log lock"); + log.pending.remove(&op_id); + log.cancellations.push((op_id, reason)); + } +} + +struct ErrorAfterYieldHost(bool); + +impl HostFunction for ErrorAfterYieldHost { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> Result { + if !self.0 { + self.0 = true; + Ok(CallOutcome::Yield) + } else { + Err(VmError::HostError( + "callback resumed into failure".to_string(), + )) + } + } +} + +impl HostFunction for SyntheticStreamHost { + fn call(&mut self, vm: &mut Vm, args: &[Value]) -> Result { + let [callback] = args else { + return Err(VmError::HostError("expected one callback".to_string())); + }; + let driver = SyntheticDriver { + items: (1..=4) + .map(|number| { + map([ + ("kind", string("item")), + ("n", Value::Int(number)), + ( + "action", + string(if self.invalid_first && number == 1 { + "invalid" + } else if number == 3 { + "stop" + } else { + "continue" + }), + ), + ]) + }) + .collect(), + polls: Arc::clone(&self.polls), + applied: Arc::clone(&self.applied), + stopped: Arc::clone(&self.stopped), + producer_error: self.producer_error, + }; + let outcome = vm.submit_callable_stream(callback.clone(), driver)?; + Ok(outcome) + } +} + +fn setup(source: &str) -> (Vm, Arc, Arc, Arc) { + let compiled = compile_source(source).expect("stream source should compile"); + let polls = Arc::new(AtomicUsize::new(0)); + let applied = Arc::new(AtomicUsize::new(0)); + let stopped = Arc::new(AtomicUsize::new(0)); + let mut vm = Vm::new(compiled.program); + vm.set_async_bridge(Box::new(PendingBridge::default())); + for function in compiled.functions { + match function.name.as_str() { + "synthetic_stream" | "synthetic_invalid" | "synthetic_error" => { + let invalid_first = function.name == "synthetic_invalid"; + let producer_error = function.name == "synthetic_error"; + vm.register_function(Box::new(SyntheticStreamHost { + polls: Arc::clone(&polls), + applied: Arc::clone(&applied), + stopped: Arc::clone(&stopped), + invalid_first, + producer_error, + })); + } + "yield_once" => { + vm.register_function(Box::new(YieldOnceHost(false))); + } + "wait_once" => { + vm.register_function(Box::new(WaitHost)); + } + "error_after_yield" => { + vm.register_function(Box::new(ErrorAfterYieldHost(false))); + } + "runtime::exit" => { + vm.register_function(Box::new(RuntimeExitHost)); + } + other => panic!("unexpected host import {other}"), + } + } + (vm, polls, applied, stopped) +} + +fn poll_once(vm: &mut Vm) -> Poll> { + vm.poll_waiting_host_op(&mut context()) +} + +fn direct_callback_vm(source: &str, export: &str) -> (Vm, Value) { + let compiled = compile_source(source).expect("direct callback source should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + let callback = vm.resolve_exported_callable(export).unwrap(); + (vm, callback) +} + +#[test] +fn delivers_three_maps_to_a_closure_in_order_and_returns_summary() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + synthetic_stream(|item| item); + "#; + let (mut vm, polls, applied, stopped) = setup(source); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + for expected in 1..=3 { + let poll = poll_once(&mut vm); + if expected < 3 { + assert!(matches!(poll, Poll::Pending)); + } else { + assert!(matches!(poll, Poll::Ready(Ok(())))); + } + assert_eq!(polls.load(Ordering::SeqCst), expected); + assert_eq!(applied.load(Ordering::SeqCst), expected); + } + assert_eq!(stopped.load(Ordering::SeqCst), 1); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + assert_eq!( + map_field(&vm.stack()[0], "outcome"), + Some(&string("stopped")) + ); + assert_eq!(map_field(&vm.stack()[0], "items"), Some(&Value::Int(3))); +} + +#[tokio::test(flavor = "current_thread")] +async fn ready_callbacks_self_wake_until_the_stream_reaches_a_terminal_result() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + synthetic_stream(|item| item); + "#; + let (mut vm, polls, applied, stopped) = setup(source); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + + tokio::time::timeout( + std::time::Duration::from_millis(100), + vm.await_waiting_host_op(), + ) + .await + .expect("ready producer and callback must make executor-driven progress") + .unwrap(); + + assert_eq!(polls.load(Ordering::SeqCst), 3); + assert_eq!(applied.load(Ordering::SeqCst), 3); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); +} + +#[test] +fn continuing_callback_returns_pending_after_scheduling_its_own_repoll() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + synthetic_stream(|item| item); + "#; + let (mut vm, polls, applied, _) = setup(source); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + let wake = Arc::new(CountingWake::default()); + let waker = Waker::from(Arc::clone(&wake)); + let mut cx = Context::from_waker(&waker); + + assert!(matches!(vm.poll_waiting_host_op(&mut cx), Poll::Pending)); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 1); + assert_eq!(wake.0.load(Ordering::SeqCst), 1); + vm.reset_for_reuse(); +} + +#[test] +fn producer_is_not_polled_until_callback_action_is_applied() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + synthetic_stream(|item| item); + "#; + let (mut vm, polls, applied, _) = setup(source); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + assert!(matches!(poll_once(&mut vm), Poll::Pending)); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 1); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + vm.reset_for_reuse(); +} + +#[test] +fn invalid_action_aborts_before_a_second_producer_poll() { + let source = r#" + fn synthetic_invalid(callback: fn(map) -> map) -> map; + synthetic_invalid(|item| item); + "#; + let (mut vm, polls, _, _) = setup(source); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + let Poll::Ready(Err(VmError::HostError(message))) = poll_once(&mut vm) else { + panic!("invalid action should fail immediately") + }; + assert!(message.contains("invalid synthetic stream action")); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert!(vm.waiting_host_op_id().is_none()); +} + +#[test] +fn producer_error_releases_the_driver_and_clears_stream_waiting_state() { + let source = r#" + fn synthetic_error(callback: fn(map) -> map) -> map; + synthetic_error(|item| item); + "#; + let (mut vm, polls, applied, stopped) = setup(source); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + let Poll::Ready(Err(VmError::HostError(message))) = poll_once(&mut vm) else { + panic!("producer error should terminate the stream") + }; + assert_eq!(message, "synthetic producer failed"); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 0); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + assert!(vm.waiting_host_op_id().is_none()); +} + +#[test] +fn yielded_callback_resumes_before_the_producer_is_polled_again() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + fn yield_once(); + fn callback(item: map) -> map { yield_once(); item } + synthetic_stream(callback); + "#; + let (mut vm, polls, applied, _) = setup(source); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + assert!(matches!(poll_once(&mut vm), Poll::Ready(Ok(())))); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 0); + assert_eq!( + vm.resume().unwrap(), + VmStatus::Waiting(vm.waiting_host_op_id().unwrap()) + ); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 1); +} + +#[test] +fn waiting_callback_resumes_to_the_outer_stream_continuation() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + fn wait_once(); + fn callback(item: map) -> map { wait_once(); item } + synthetic_stream(callback); + "#; + let (mut vm, polls, applied, _) = setup(source); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + assert!(matches!(poll_once(&mut vm), Poll::Ready(Ok(())))); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + let inner_id = vm.waiting_host_op_id().unwrap(); + assert_ne!(inner_id, 0); + vm.complete_host_op(inner_id, vec![Value::Null]).unwrap(); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 1); +} + +#[test] +fn resumed_callback_error_releases_the_stream_driver() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + fn error_after_yield(); + fn callback(item: map) -> map { error_after_yield(); item } + synthetic_stream(callback); + "#; + let (mut vm, polls, applied, stopped) = setup(source); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + assert!(matches!(poll_once(&mut vm), Poll::Ready(Ok(())))); + assert!( + matches!(vm.resume(), Err(VmError::HostError(message)) if message == "callback resumed into failure") + ); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 0); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + assert!(vm.waiting_host_op_id().is_none()); +} + +#[test] +fn runtime_exit_in_callback_retires_the_direct_stream_before_reporting_failure() { + let source = r#" + use runtime; + fn synthetic_stream(callback: fn(map) -> map) -> map; + fn yield_once(); + pub fn callback(item: map) -> map { yield_once(); runtime::exit(); item } + synthetic_stream(callback); + "#; + let (mut vm, polls, applied, stopped) = setup(source); + let VmStatus::Waiting(op_id) = vm.run().unwrap() else { + panic!("stream should wait for its first producer item") + }; + + assert!(matches!(poll_once(&mut vm), Poll::Ready(Ok(())))); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 0); + assert_eq!(stopped.load(Ordering::SeqCst), 0); + + let VmError::InvalidFrameState(message) = vm + .resume() + .expect_err("runtime::exit in the callback should report a typed terminal failure") + else { + panic!("runtime::exit in the callback should report invalid callback completion") + }; + assert_eq!(message, "callable stream callback returned no action"); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 0); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + assert!(vm.waiting_host_op_id().is_none()); + + assert!(matches!(poll_once(&mut vm), Poll::Ready(Ok(())))); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + let late = vm + .complete_host_op(op_id, vec![Value::Null]) + .expect_err("a retired stream must reject late completion"); + assert!(late.to_string().contains("vm is not waiting on any op")); + + let callback = vm.resolve_exported_callable("callback").unwrap(); + let replacement_stopped = Arc::new(AtomicUsize::new(0)); + assert!(matches!( + vm.submit_callable_stream( + callback, + DropOnlyDriver { + stopped: Arc::clone(&replacement_stopped), + }, + ) + .unwrap(), + CallOutcome::Pending(_) + )); + vm.reset_for_reuse(); + assert_eq!(replacement_stopped.load(Ordering::SeqCst), 1); + assert_eq!(stopped.load(Ordering::SeqCst), 1); +} + +#[test] +fn runtime_exit_in_callback_is_a_fused_typed_invocation_failure() { + let source = r#" + use runtime; + fn synthetic_stream(callback: fn(map) -> map) -> map; + fn callback(item: map) -> map { runtime::exit(); item } + pub fn run() -> map { synthetic_stream(callback) } + "#; + let (mut vm, polls, applied, stopped) = setup(source); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + let callable = vm.resolve_exported_callable("run").unwrap(); + { + let mut invocation = vm.start_invocation(callable, vec![]).unwrap(); + assert!(matches!( + invocation.poll_next().unwrap(), + InvocationPoll::Ready(Some(Err(InvocationError::Vm(VmError::InvalidFrameState( + "callable stream callback returned no action" + ))))) + )); + assert!(matches!( + invocation.poll_next().unwrap(), + InvocationPoll::Ready(None) + )); + assert!(matches!( + invocation.poll_next().unwrap(), + InvocationPoll::Ready(None) + )); + } + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 0); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + assert!(vm.waiting_host_op_id().is_none()); +} + +#[test] +fn invocation_cancellation_during_callback_wait_releases_the_stream_driver() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + fn wait_once(); + fn callback(item: map) -> map { wait_once(); item } + pub fn run() -> map { synthetic_stream(callback) } + "#; + let (mut vm, polls, applied, stopped) = setup(source); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + let callable = vm.resolve_exported_callable("run").unwrap(); + { + let mut invocation = vm.start_invocation(callable, vec![]).unwrap(); + assert!(matches!( + invocation.poll_next().unwrap(), + InvocationPoll::Pending + )); + assert_eq!(polls.load(Ordering::SeqCst), 1); + invocation.cancel(CancellationReason::Requested).unwrap(); + assert!(matches!( + invocation.poll_next().unwrap(), + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + CancellationReason::Requested + )))) + )); + } + assert_eq!(applied.load(Ordering::SeqCst), 0); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + assert!(vm.waiting_host_op_id().is_none()); +} + +#[test] +fn dropping_invocation_during_callback_wait_cancels_once_and_reuses_the_vm() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + fn wait_once(); + fn callback(item: map) -> map { wait_once(); item } + pub fn run() -> map { synthetic_stream(callback) } + pub fn plain() -> int { 42 } + "#; + let (mut vm, polls, applied, stopped) = setup(source); + let cancellations = Arc::new(Mutex::new(CancellationLog::default())); + vm.set_async_bridge(Box::new(RecordingPendingBridge(Arc::clone(&cancellations)))); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + let run = vm.resolve_exported_callable("run").unwrap(); + let plain = vm.resolve_exported_callable("plain").unwrap(); + + { + let mut invocation = vm.start_invocation(run, vec![]).unwrap(); + assert!(matches!( + invocation.poll_next().unwrap(), + InvocationPoll::Pending + )); + assert_eq!(polls.load(Ordering::SeqCst), 1); + } + + assert_eq!(applied.load(Ordering::SeqCst), 0); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + let cancelled = cancellations + .lock() + .expect("cancellation log lock") + .cancellations + .clone(); + assert_eq!(cancelled.len(), 1); + assert_eq!(cancelled[0].1, CancellationReason::Requested); + let late = vm + .complete_host_op(cancelled[0].0, vec![Value::Null]) + .expect_err("a cancelled callback wait must reject late completion"); + assert!(late.to_string().contains("vm is not waiting on any op")); + + let mut replacement = vm.start_invocation(plain, vec![]).unwrap(); + assert!(matches!( + replacement.poll_next().unwrap(), + InvocationPoll::Ready(Some(Ok(vm::InvocationItem::Complete(Value::Int(42))))) + )); +} + +#[test] +fn dropping_invocation_during_callback_yield_does_not_resume_the_callback() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + fn yield_forever(); + fn callback(item: map) -> map { yield_forever(); item } + pub fn run() -> map { synthetic_stream(callback) } + pub fn plain() -> int { 42 } + "#; + let compiled = compile_source(source).expect("stream source should compile"); + let polls = Arc::new(AtomicUsize::new(0)); + let applied = Arc::new(AtomicUsize::new(0)); + let stopped = Arc::new(AtomicUsize::new(0)); + let callback_calls = Arc::new(AtomicUsize::new(0)); + let mut vm = Vm::new(compiled.program); + vm.set_async_bridge(Box::new(PendingBridge::default())); + for function in compiled.functions { + match function.name.as_str() { + "synthetic_stream" => { + vm.register_function(Box::new(SyntheticStreamHost { + polls: Arc::clone(&polls), + applied: Arc::clone(&applied), + stopped: Arc::clone(&stopped), + invalid_first: false, + producer_error: false, + })); + } + "yield_forever" => { + vm.register_function(Box::new(YieldForeverHost(Arc::clone(&callback_calls)))); + } + other => panic!("unexpected host import {other}"), + } + } + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + let run = vm.resolve_exported_callable("run").unwrap(); + let plain = vm.resolve_exported_callable("plain").unwrap(); + + { + let mut invocation = vm.start_invocation(run, vec![]).unwrap(); + assert!(matches!( + invocation.poll_next().unwrap(), + InvocationPoll::Pending + )); + assert_eq!(callback_calls.load(Ordering::SeqCst), 2); + } + + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 0); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + assert_eq!(callback_calls.load(Ordering::SeqCst), 2); + let mut replacement = vm.start_invocation(plain, vec![]).unwrap(); + assert!(matches!( + replacement.poll_next().unwrap(), + InvocationPoll::Ready(Some(Ok(vm::InvocationItem::Complete(Value::Int(42))))) + )); +} + +#[test] +fn explicit_cancel_then_drop_cancels_callback_wait_and_driver_once() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + fn wait_once(); + fn callback(item: map) -> map { wait_once(); item } + pub fn run() -> map { synthetic_stream(callback) } + "#; + let (mut vm, polls, applied, stopped) = setup(source); + let cancellations = Arc::new(Mutex::new(CancellationLog::default())); + vm.set_async_bridge(Box::new(RecordingPendingBridge(Arc::clone(&cancellations)))); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + let run = vm.resolve_exported_callable("run").unwrap(); + + { + let mut invocation = vm.start_invocation(run, vec![]).unwrap(); + assert!(matches!( + invocation.poll_next().unwrap(), + InvocationPoll::Pending + )); + invocation.cancel(CancellationReason::Deadline).unwrap(); + } + + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 0); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + let cancelled = &cancellations + .lock() + .expect("cancellation log lock") + .cancellations; + assert_eq!(cancelled.len(), 1); + assert_eq!(cancelled[0].1, CancellationReason::Deadline); +} + +#[test] +fn dropping_invocation_during_producer_wait_retires_the_stream_and_reuses_the_vm() { + let source = r#" + fn synthetic_pending(callback: fn(map) -> map) -> map; + pub fn run() -> map { synthetic_pending(|item| item) } + pub fn plain() -> int { 42 } + "#; + let compiled = compile_source(source).expect("stream source should compile"); + let polls = Arc::new(AtomicUsize::new(0)); + let applied = Arc::new(AtomicUsize::new(0)); + let stopped = Arc::new(AtomicUsize::new(0)); + let op_id = Arc::new(AtomicUsize::new(0)); + let mut vm = Vm::new(compiled.program); + for function in compiled.functions { + match function.name.as_str() { + "synthetic_pending" => { + vm.register_function(Box::new(PendingProducerHost { + polls: Arc::clone(&polls), + applied: Arc::clone(&applied), + stopped: Arc::clone(&stopped), + op_id: Arc::clone(&op_id), + })); + } + other => panic!("unexpected host import {other}"), + } + } + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + let run = vm.resolve_exported_callable("run").unwrap(); + let plain = vm.resolve_exported_callable("plain").unwrap(); + + { + let mut invocation = vm.start_invocation(run, vec![]).unwrap(); + assert!(matches!( + invocation.poll_next().unwrap(), + InvocationPoll::Pending + )); + assert_eq!(polls.load(Ordering::SeqCst), 1); + } + + assert_eq!(stopped.load(Ordering::SeqCst), 1); + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(applied.load(Ordering::SeqCst), 0); + let retired_op_id = op_id.load(Ordering::SeqCst) as HostOpId; + let late = vm + .complete_host_op(retired_op_id, vec![Value::Null]) + .expect_err("a retired invocation must reject late completion"); + assert!(late.to_string().contains("vm is not waiting on any op")); + + let mut replacement = vm + .start_invocation(plain, vec![]) + .expect("the vm must accept a replacement invocation immediately"); + assert!(matches!( + replacement.poll_next().unwrap(), + InvocationPoll::Ready(Some(Ok(vm::InvocationItem::Complete(Value::Int(42))))) + )); +} + +#[test] +fn reset_and_shutdown_release_a_waiting_stream_driver() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + synthetic_stream(|item| item); + "#; + let (mut reset_vm, ..) = setup(source); + assert!(matches!(reset_vm.run().unwrap(), VmStatus::Waiting(_))); + reset_vm.reset_for_reuse(); + assert!(reset_vm.waiting_host_op_id().is_none()); + + let (mut shutdown_vm, ..) = setup(source); + assert!(matches!(shutdown_vm.run().unwrap(), VmStatus::Waiting(_))); + shutdown_vm.shutdown(); + assert!(shutdown_vm.waiting_host_op_id().is_none()); +} + +fn enter_callback_wait(vm: &mut Vm) { + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + assert!(matches!(poll_once(vm), Poll::Ready(Ok(())))); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); +} + +#[test] +fn reset_shutdown_and_drop_release_a_stream_during_callback_wait() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + fn wait_once(); + fn callback(item: map) -> map { wait_once(); item } + synthetic_stream(callback); + "#; + + let (mut reset_vm, _, _, reset_stopped) = setup(source); + enter_callback_wait(&mut reset_vm); + reset_vm.reset_for_reuse(); + assert_eq!(reset_stopped.load(Ordering::SeqCst), 1); + assert!(reset_vm.waiting_host_op_id().is_none()); + + let (mut shutdown_vm, _, _, shutdown_stopped) = setup(source); + enter_callback_wait(&mut shutdown_vm); + shutdown_vm.shutdown(); + assert_eq!(shutdown_stopped.load(Ordering::SeqCst), 1); + assert!(shutdown_vm.waiting_host_op_id().is_none()); + + let (mut dropped_vm, _, _, drop_stopped) = setup(source); + enter_callback_wait(&mut dropped_vm); + drop(dropped_vm); + assert_eq!(drop_stopped.load(Ordering::SeqCst), 1); +} + +#[test] +fn direct_submit_rejects_wrong_schema_before_admitting_the_driver() { + let (mut vm, callback) = + direct_callback_vm(r#"pub fn callback(item: int) -> int { item }"#, "callback"); + let stopped = Arc::new(AtomicUsize::new(0)); + let error = vm + .submit_callable_stream( + callback, + DropOnlyDriver { + stopped: Arc::clone(&stopped), + }, + ) + .expect_err("wrong callback schema must be rejected"); + assert!(matches!(error, VmError::TypeMismatch("fn(map) -> map"))); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + assert!(vm.waiting_host_op_id().is_none()); +} + +#[test] +fn direct_submit_rejects_foreign_callable_with_matching_prototype_metadata() { + let source = r#"pub fn callback(item: map) -> map { item }"#; + let (foreign_vm, foreign_callback) = direct_callback_vm(source, "callback"); + let (mut receiving_vm, receiving_callback) = direct_callback_vm(source, "callback"); + let (Value::Callable(foreign), Value::Callable(receiving)) = + (&foreign_callback, &receiving_callback) + else { + panic!("exports must be callables") + }; + assert_eq!(foreign.prototype_id, receiving.prototype_id); + let stopped = Arc::new(AtomicUsize::new(0)); + + let error = receiving_vm + .submit_callable_stream( + foreign_callback, + DropOnlyDriver { + stopped: Arc::clone(&stopped), + }, + ) + .expect_err("callable from another vm must be rejected"); + assert!(matches!(error, VmError::InvalidCallable)); + assert_eq!(stopped.load(Ordering::SeqCst), 1); + assert!(receiving_vm.waiting_host_op_id().is_none()); + drop(foreign_vm); +} + +#[test] +fn terminal_stream_rejects_late_completion_through_the_direct_vm_api() { + let (mut vm, callback) = + direct_callback_vm(r#"pub fn callback(item: map) -> map { item }"#, "callback"); + let polls = Arc::new(AtomicUsize::new(0)); + let applied = Arc::new(AtomicUsize::new(0)); + let stopped = Arc::new(AtomicUsize::new(0)); + let CallOutcome::Pending(op_id) = vm + .submit_callable_stream( + callback, + SyntheticDriver { + items: VecDeque::from([map([("action", string("stop"))])]), + polls, + applied, + stopped, + producer_error: false, + }, + ) + .unwrap() + else { + panic!("stream admission must return pending") + }; + + assert!(matches!(poll_once(&mut vm), Poll::Ready(Ok(())))); + let error = vm + .complete_host_op(op_id, vec![Value::Null]) + .expect_err("terminal stream must reject a late completion"); + assert!(error.to_string().contains("vm is not waiting on any op")); +} + +#[test] +fn callback_schema_accepts_closures_and_named_generic_functions() { + for source in [ + r#"fn synthetic_stream(callback: fn(map) -> map) -> map; synthetic_stream(|value| value);"#, + r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + fn identity(value: T) -> T { value } + synthetic_stream(identity); + "#, + ] { + compile_source(source).expect("typed callable should compile"); + } +} + +#[test] +fn callback_schema_mismatches_are_rejected_at_compile_time() { + for source in [ + r#"fn synthetic_stream(callback: fn(map) -> map) -> map; synthetic_stream(|value, extra| {action: "stop"});"#, + r#"fn synthetic_stream(callback: fn(map) -> map) -> map; synthetic_stream(|value: int| {action: "stop"});"#, + r#"fn synthetic_stream(callback: fn(map) -> map) -> map; synthetic_stream(|value| 1);"#, + ] { + assert!( + compile_source(source).is_err(), + "source unexpectedly compiled: {source}" + ); + } +} + +#[test] +fn dropping_vm_releases_a_waiting_stream_driver_once() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + synthetic_stream(|item| item); + "#; + let (mut vm, _polls, _applied, stopped) = setup(source); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + drop(vm); + assert_eq!(stopped.load(Ordering::SeqCst), 1); +} + +#[test] +fn interpreter_jit_and_aot_use_the_same_host_stream_continuation() { + let source = r#" + fn synthetic_stream(callback: fn(map) -> map) -> map; + let mut warm = 0; + while warm < 100 { + warm = warm + 1; + } + synthetic_stream(|item| item); + "#; + let mut backends = vec!["interpreter"]; + #[cfg(feature = "cranelift-jit")] + backends.extend(["jit", "aot"]); + for backend in backends { + let (mut vm, polls, applied, _stopped) = setup(source); + vm.set_jit_config(JitConfig { + enabled: backend == "jit", + hot_loop_threshold: 1, + max_trace_len: 128, + }); + if backend == "aot" { + vm.compile_aot().expect("aot compile should succeed"); + } + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + assert!(matches!(poll_once(&mut vm), Poll::Pending)); + assert!(matches!(poll_once(&mut vm), Poll::Pending)); + assert!(matches!(poll_once(&mut vm), Poll::Ready(Ok(())))); + assert_eq!(vm.run().unwrap(), VmStatus::Halted, "{backend}"); + assert_eq!(polls.load(Ordering::SeqCst), 3, "{backend}"); + assert_eq!(applied.load(Ordering::SeqCst), 3, "{backend}"); + if backend == "jit" && native_jit_supported() { + assert!( + vm.jit_native_exec_count() > 0, + "jit stream setup must execute a native hot path: {}", + vm.dump_jit_info() + ); + } + if backend == "aot" { + assert!(vm.aot_exec_count() > 0, "aot stream path must execute"); + } + } +} + +fn native_jit_supported() -> bool { + (cfg!(target_arch = "x86_64") + && (cfg!(target_os = "windows") || (cfg!(unix) && !cfg!(target_os = "macos")))) + || (cfg!(target_arch = "aarch64") + && (cfg!(target_os = "linux") || cfg!(target_os = "macos"))) +} diff --git a/tests/vm/http_host_tests.rs b/tests/vm/http_host_tests.rs index 531cd74c..d1291ee4 100644 --- a/tests/vm/http_host_tests.rs +++ b/tests/vm/http_host_tests.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::io::{Read, Write}; use std::net::TcpListener; +use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; use std::thread; @@ -132,7 +133,8 @@ async fn drive_vm_to_halt(vm: &mut Vm) -> Result<(), vm::VmError> { 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)); + 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) @@ -311,6 +313,31 @@ fn cached_plan_refreshes_after_a_sibling_registry_mutation() { .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 = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = listener.accept().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())); @@ -320,7 +347,8 @@ async fn explicitly_allowed_http_capability_reaches_http_policy() { 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 @@ -346,7 +374,8 @@ fn http_in_flight_limit_rejects_before_starting_a_request() { 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"); @@ -355,3 +384,201 @@ fn http_in_flight_limit_rejects_before_starting_a_request() { .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!(defaults.max_websocket_frame_bytes > 0); + assert!(defaults.max_websocket_send_bytes > 0); + assert_eq!( + defaults.max_stream_duration, + std::time::Duration::from_secs(5 * 60) + ); + assert!(!defaults.stream_idle_timeout.is_zero()); + assert!(!defaults.websocket_close_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_websocket_frame_bytes: 0, + ..defaults.clone() + }, + HttpConfig { + max_websocket_send_bytes: 0, + ..defaults.clone() + }, + HttpConfig { + max_stream_duration: std::time::Duration::ZERO, + ..defaults.clone() + }, + HttpConfig { + stream_idle_timeout: std::time::Duration::ZERO, + ..defaults.clone() + }, + HttpConfig { + websocket_close_timeout: std::time::Duration::ZERO, + ..defaults + }, + ]; + 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()); +} + +#[derive(Default)] +struct RetirementState { + submitted: HashMap, + retired: Vec, +} + +struct RetirementBridge { + state: Arc>, +} + +impl HostAsyncBridge for RetirementBridge { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + self.state + .lock() + .expect("retirement state lock") + .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 cancel_op(&mut self, op_id: HostOpId) { + let mut state = self.state.lock().expect("retirement state lock"); + state.submitted.remove(&op_id); + state.retired.push(op_id); + } +} + +fn pending_http_vm(state: Arc>) -> Vm { + let mut vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + vm.set_http_max_in_flight(1); + vm.configure_http(local_http_config(1)) + .expect("HTTP configuration should be valid"); + vm.set_async_bridge(Box::new(RetirementBridge { state })); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + assert!(matches!(vm.run(), Ok(VmStatus::Waiting(_)))); + vm +} + +#[test] +fn reset_retires_buffered_http_future_and_releases_its_permit() { + let state = Arc::new(Mutex::new(RetirementState::default())); + let mut vm = pending_http_vm(Arc::clone(&state)); + + vm.reset_for_reuse(); + + let retired_id = { + let state = state.lock().expect("retirement state lock"); + assert_eq!(state.submitted.len(), 0); + assert_eq!(state.retired.len(), 1); + state.retired[0] + }; + assert!( + vm.complete_host_op(retired_id, CallReturn::none()).is_err(), + "a retired future must not complete back into the VM" + ); + vm.configure_http(local_http_config(1)) + .expect("HTTP policy should remain reusable after reset"); + assert!( + matches!(vm.run(), Ok(VmStatus::Waiting(_))), + "a second request should acquire the released permit" + ); +} + +#[test] +fn shutdown_and_drop_retire_buffered_http_futures() { + let shutdown_state = Arc::new(Mutex::new(RetirementState::default())); + let mut vm = pending_http_vm(Arc::clone(&shutdown_state)); + vm.shutdown(); + { + let state = shutdown_state.lock().expect("retirement state lock"); + assert!(state.submitted.is_empty()); + assert_eq!(state.retired.len(), 1); + } + + let drop_state = Arc::new(Mutex::new(RetirementState::default())); + drop(pending_http_vm(Arc::clone(&drop_state))); + let state = drop_state.lock().expect("retirement state lock"); + assert!(state.submitted.is_empty()); + assert_eq!(state.retired.len(), 1); +} diff --git a/tests/vm/http_sse_tests.rs b/tests/vm/http_sse_tests.rs new file mode 100644 index 00000000..c4257e68 --- /dev/null +++ b/tests/vm/http_sse_tests.rs @@ -0,0 +1,1026 @@ +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::mpsc; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use std::task::{Context, Poll}; +use std::thread; + +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); + } +} + +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 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()); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + drive(&mut vm).await.map(|()| vm) +} + +fn server(response_parts: Vec<&'static [u8]>) -> (u16, thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + assert!(request.starts_with("get /events http/1.1")); + assert!(request.contains("accept: text/event-stream")); + for part in response_parts { + stream.write_all(part).unwrap(); + stream.flush().unwrap(); + } + }); + (port, handle) +} + +fn recording_server( + responses: Vec>, +) -> (u16, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let (sender, receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + for response_parts in responses { + let (mut stream, _) = listener.accept().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]); + } + 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]; + stream.read_exact(&mut body).unwrap(); + sender + .send(format!("{head}{}", String::from_utf8_lossy(&body))) + .unwrap(); + for part in response_parts { + stream.write_all(part).unwrap(); + stream.flush().unwrap(); + } + } + }); + (port, receiver, handle) +} + +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) -> thread::JoinHandle<()> { + thread::spawn(move || { + listener.set_nonblocking(true).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500); + loop { + match listener.accept() { + Ok(_) => panic!("{context} must be rejected before a second connection"), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + return; + } + thread::sleep(std::time::Duration::from_millis(5)); + } + Err(error) => panic!("unexpected accept error: {error}"), + } + } + }) +} + +fn rejecting_redirect_server( + location: impl FnOnce(u16) -> String + Send + 'static, +) -> (u16, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let location = location(port); + let (sender, receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().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); + listener.set_nonblocking(true).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500); + loop { + match listener.accept() { + Ok(_) => panic!("invalid redirect must be rejected before a second connection"), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if std::time::Instant::now() >= deadline { + return; + } + thread::sleep(std::time::Duration::from_millis(5)); + } + Err(error) => panic!("unexpected accept error: {error}"), + } + } + }); + (port, receiver, 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()); + 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()); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + vm.reset_for_reuse(); +} + +#[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 vm = run_sse_source(&source, config(port)).await.unwrap(); + assert_eq!(field(&vm.stack()[0], "outcome"), &Value::string("eof")); + let request = requests.recv().unwrap().to_ascii_lowercase(); + assert!(request.starts_with("post /events http/1.1")); + assert!(request.ends_with("payload")); + server.join().unwrap(); +} + +fn redirect_server(status: u16) -> (u16, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let (sender, receiver) = mpsc::channel(); + let handle = thread::spawn(move || { + for index in 0..2 { + let (mut stream, _) = listener.accept().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]); + } + 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(); + } + } + }); + (port, receiver, 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().to_ascii_lowercase(); + let second = requests.recv().unwrap().to_ascii_lowercase(); + assert!(first.starts_with("post /start http/1.1")); + if preserves_post { + assert!( + second.starts_with("post /final http/1.1"), + "status {status}: {second}" + ); + assert!(second.ends_with("payload"), "status {status}: {second}"); + } else { + assert!( + second.starts_with("get /final http/1.1"), + "status {status}: {second}" + ); + assert!(!second.ends_with("payload"), "status {status}: {second}"); + } + 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().to_ascii_lowercase(); + assert!(request.contains("authorization:")); + assert!(request.contains("cookie: 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 = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + 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().to_ascii_lowercase(); + assert!(request.contains("authorization:")); + assert!(request.contains("cookie: 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_reset_releases_the_connection_permit_before_reuse() { + let (port, server) = server(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()); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + vm.reset_for_reuse(); + drive(&mut vm).await.unwrap(); + 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 = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = listener.accept().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(); + thread::sleep(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 = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = [0; 1024]; + let read = socket.read(&mut request).unwrap(); + assert!(read > 0, "SSE request should be received"); + thread::sleep(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 = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = [0; 1024]; + assert!(socket.read(&mut request).unwrap() > 0); + thread::sleep(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 = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + let mut request = [0; 1024]; + assert!(socket.read(&mut request).unwrap() > 0); + thread::sleep(std::time::Duration::from_millis(80)); + }); + 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); + 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 = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = listener.accept().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 { + thread::sleep(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()); + 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 = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut first, _) = listener.accept().unwrap(); + let mut request = [0; 1024]; + assert!(first.read(&mut request).unwrap() > 0); + let first = thread::spawn(move || { + thread::sleep(std::time::Duration::from_millis(80)); + drop(first); + }); + + let (mut second, _) = listener.accept().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()); + 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(); + 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 = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut first, _) = listener.accept().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 || { + thread::sleep(std::time::Duration::from_millis(500)); + drop(first); + }); + + let (mut second, _) = listener.accept().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()); + 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(); + 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 = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut socket, _) = listener.accept().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(); + thread::sleep(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()); + 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_revalidates_redirects_and_strips_cross_origin_credentials() { + 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 307 Temporary 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", Cookie: "a=b"}}}}, + 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().to_ascii_lowercase(); + assert!(first.starts_with("post /start http/1.1")); + assert!(first.ends_with("payload")); + assert!(first.contains("authorization: bearer secret")); + assert!(first.contains("cookie: a=b")); + let second = target_requests.recv().unwrap().to_ascii_lowercase(); + assert!(second.starts_with("post /final http/1.1")); + assert!(second.ends_with("payload")); + assert!(!second.contains("authorization:")); + assert!(!second.contains("cookie:")); + source_server.join().unwrap(); + target.join().unwrap(); +} diff --git a/tests/vm/http_websocket_tests.rs b/tests/vm/http_websocket_tests.rs new file mode 100644 index 00000000..c01b12d2 --- /dev/null +++ b/tests/vm/http_websocket_tests.rs @@ -0,0 +1,1513 @@ +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_tungstenite::tungstenite::handshake::server::{Request, Response}; +use tokio_tungstenite::tungstenite::protocol::Message; +use vm::{ + CallOutcome, CallReturn, HostAsyncBridge, HostFunctionRegistry, HostFuture, HostFutureOutput, + HostOpId, HostStackFunction, HttpConfig, HttpHostExt, Value, Vm, VmError, VmResult, VmStatus, + compile_source, default_host_callables, +}; + +#[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); + } +} + +struct AsyncWaitOnce; + +impl HostStackFunction for AsyncWaitOnce { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> VmResult { + vm.submit_host_future(Box::pin(async move { + tokio::time::sleep(Duration::from_millis(250)).await; + Ok(HostFutureOutput::returning(CallReturn::one(Value::Bool( + true, + )))) + })) + } +} + +struct CountCloseCallbacks { + calls: Arc, +} + +impl HostStackFunction for CountCloseCallbacks { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(CallOutcome::Return(CallReturn::one(Value::Bool(true)))) + } +} + +struct CaptureReads { + inner: tokio::net::TcpStream, + enabled: Arc, + bytes: Arc>>, +} + +impl AsyncRead for CaptureReads { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let before = buf.filled().len(); + let result = Pin::new(&mut self.inner).poll_read(cx, buf); + if result.is_ready() && self.enabled.load(Ordering::SeqCst) { + self.bytes + .lock() + .unwrap() + .extend_from_slice(&buf.filled()[before..]); + } + result + } +} + +impl AsyncWrite for CaptureReads { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +fn decode_masked_close(frame: &[u8]) -> (u16, String) { + assert_eq!(frame[0], 0x88); + assert_ne!(frame[1] & 0x80, 0); + let payload_len = usize::from(frame[1] & 0x7f); + assert_eq!(frame.len(), 6 + payload_len); + let mask: [u8; 4] = frame[2..6].try_into().unwrap(); + let payload = frame[6..] + .iter() + .enumerate() + .map(|(index, byte)| byte ^ mask[index & 3]) + .collect::>(); + ( + u16::from_be_bytes([payload[0], payload[1]]), + String::from_utf8(payload[2..].to_vec()).unwrap(), + ) +} + +fn websocket_config(port: u16) -> HttpConfig { + HttpConfig { + allowed_schemes: vec!["ws".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![port], + allow_private_ips: true, + connect_timeout: Duration::from_secs(1), + request_timeout: Duration::from_secs(2), + stream_idle_timeout: Duration::from_millis(500), + websocket_close_timeout: Duration::from_millis(500), + ..HttpConfig::default() + } +} + +fn map_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!("map missing field {key}")) +} + +async fn run_websocket(source: &str, config: HttpConfig) -> Result { + let compiled = compile_source(source).expect("WebSocket source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_http(config)?; + HostFunctionRegistry::new().bind_vm_cached(&mut vm)?; + let mut status = vm.run()?; + loop { + match status { + VmStatus::Halted => return Ok(vm), + VmStatus::Yielded => status = vm.resume()?, + VmStatus::Waiting(_) => { + vm.await_waiting_host_op().await?; + status = vm.resume()?; + } + } + } +} + +async fn drive_websocket(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 poll_websocket_once(vm: &mut Vm) -> VmResult { + std::future::poll_fn(|cx| { + Poll::Ready(match vm.poll_waiting_host_op(cx) { + Poll::Ready(result) => result.map(|()| true), + Poll::Pending => Ok(false), + }) + }) + .await +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_callback_wait_cannot_return_stop_after_the_total_deadline() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let _socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + tokio::time::sleep(Duration::from_millis(400)).await; + }); + let source = format!( + r#" + use http; + fn async_wait() -> bool; + fn callback(item: map) -> map {{ + {{ action: if async_wait() => {{ "stop" }} else => {{ "stop" }} }} + }} + http::client::websocket({{ + url: "ws://{address}/", + timeout_ms: 90 + }}, callback); + "# + ); + let compiled = compile_source(&source).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + let mut config = websocket_config(address.port()); + config.max_stream_duration = Duration::from_secs(1); + vm.configure_http(config) + .expect("configuration should install"); + vm.set_async_bridge(Box::::default()); + let mut registry = HostFunctionRegistry::new(); + registry.register_stack("async_wait", 0, || Box::new(AsyncWaitOnce)); + registry + .bind_vm_cached(&mut vm) + .expect("imports should bind"); + + let error = drive_websocket(&mut vm) + .await + .expect_err("callback time must count against the total deadline"); + assert!(error.to_string().contains("deadline exceeded"), "{error}"); + assert!(vm.stack().iter().all(|value| { + !matches!(value, Value::Map(map) if map.get(&Value::string("outcome")) == Some(&Value::string("stopped"))) + })); + server.await.expect("server should finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_host_deadline_releases_permit_for_vm_reuse() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (first, _) = listener + .accept() + .await + .expect("first client should connect"); + let first = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(300)).await; + drop(first); + }); + let (second, _) = listener + .accept() + .await + .expect("second client should connect"); + let mut socket = tokio_tungstenite::accept_async(second) + .await + .expect("second handshake should succeed"); + let terminal = tokio::time::timeout(Duration::from_millis(300), socket.next()) + .await + .expect("second client should stop promptly"); + assert!(terminal.is_none() || terminal.is_some_and(|item| item.is_err())); + first.await.expect("first connection holder should finish"); + }); + let source = format!( + r#" + use http; + http::client::websocket({{ + url: "ws://{address}/", + timeout_ms: 500 + }}, |item| {{ action: "stop" }}); + "# + ); + let compiled = compile_source(&source).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + vm.set_http_max_in_flight(1); + let mut config = websocket_config(address.port()); + config.max_stream_duration = Duration::from_millis(100); + config.stream_idle_timeout = Duration::from_secs(1); + vm.configure_http(config) + .expect("configuration should install"); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("imports should bind"); + + let error = drive_websocket(&mut vm) + .await + .expect_err("host deadline should bound the first handshake"); + assert!(error.to_string().contains("deadline"), "{error}"); + vm.reset_for_reuse(); + drive_websocket(&mut vm) + .await + .expect("second run should acquire the released permit"); + assert_eq!( + map_field(&vm.stack()[0], "outcome"), + &Value::string("stopped") + ); + server.await.expect("server should finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn local_close_timeout_includes_delay_before_driver_repoll() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + let terminal = tokio::time::timeout(Duration::from_millis(500), socket.next()) + .await + .expect("client should terminate after close timeout"); + assert!(terminal.is_none() || terminal.is_some_and(|item| item.is_err())); + }); + let source = format!( + r#" + use http; + fn count_close_callback() -> bool; + fn callback(item: map) -> map {{ + {{ + action: if count_close_callback() => {{ "close" }} else => {{ "close" }}, + code: 1000, + reason: "done" + }} + }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let compiled = compile_source(&source).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + let mut config = websocket_config(address.port()); + config.websocket_close_timeout = Duration::from_millis(50); + config.stream_idle_timeout = Duration::from_secs(1); + config.max_stream_duration = Duration::from_secs(1); + vm.configure_http(config) + .expect("configuration should install"); + let callbacks = Arc::new(AtomicUsize::new(0)); + let mut registry = HostFunctionRegistry::new(); + registry.register_stack("count_close_callback", 0, { + let callbacks = Arc::clone(&callbacks); + move || { + Box::new(CountCloseCallbacks { + calls: Arc::clone(&callbacks), + }) + } + }); + registry + .bind_vm_cached(&mut vm) + .expect("imports should bind"); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + + tokio::time::timeout(Duration::from_millis(500), async { + while callbacks.load(Ordering::SeqCst) == 0 { + assert!(!poll_websocket_once(&mut vm).await.unwrap()); + tokio::task::yield_now().await; + } + }) + .await + .expect("local close action should be applied"); + tokio::time::sleep(Duration::from_millis(90)).await; + + let repolled_at = tokio::time::Instant::now(); + let error = poll_websocket_once(&mut vm) + .await + .expect_err("the transition-time close deadline should already be expired"); + assert!( + error.to_string().contains("close handshake timed out"), + "{error}" + ); + assert!( + repolled_at.elapsed() < Duration::from_millis(30), + "delayed repoll must not receive a fresh close timeout" + ); + server.await.expect("server should finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn peer_close_timeout_includes_delay_before_driver_repoll() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let (close_sent, mut close_sent_rx) = tokio::sync::oneshot::channel(); + let (release_server, release_server_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + socket + .send(Message::Close(Some( + tokio_tungstenite::tungstenite::protocol::CloseFrame { + code: 1001.into(), + reason: "away".into(), + }, + ))) + .await + .expect("peer close should send"); + close_sent.send(()).expect("client should await peer close"); + release_server_rx + .await + .expect("test should retain the unresolved close handshake"); + drop(socket); + }); + let source = format!( + r#" + use http; + fn count_peer_close_callback() -> bool; + fn callback(item: map) -> map {{ + if item["kind"] == "close" => {{ + assert(item["code"] == 1001); + assert(item["reason"] == "away"); + assert(count_peer_close_callback()); + {{ action: "close", code: 1000, reason: "validated" }} + }} else => {{ + {{ action: "continue", code: 1000, reason: "" }} + }} + }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let compiled = compile_source(&source).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + let mut config = websocket_config(address.port()); + config.websocket_close_timeout = Duration::from_millis(60); + config.stream_idle_timeout = Duration::from_secs(1); + config.max_stream_duration = Duration::from_secs(2); + vm.configure_http(config) + .expect("configuration should install"); + let callbacks = Arc::new(AtomicUsize::new(0)); + let mut registry = HostFunctionRegistry::new(); + registry.register_stack("count_peer_close_callback", 0, { + let callbacks = Arc::clone(&callbacks); + move || { + Box::new(CountCloseCallbacks { + calls: Arc::clone(&callbacks), + }) + } + }); + registry + .bind_vm_cached(&mut vm) + .expect("imports should bind"); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + + tokio::time::timeout(Duration::from_millis(500), async { + loop { + match close_sent_rx.try_recv() { + Ok(()) => break, + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => { + assert!(!poll_websocket_once(&mut vm).await.unwrap()); + tokio::task::yield_now().await; + } + Err(tokio::sync::oneshot::error::TryRecvError::Closed) => { + panic!("server closed before sending peer close") + } + } + } + while callbacks.load(Ordering::SeqCst) == 0 { + assert!(!poll_websocket_once(&mut vm).await.unwrap()); + tokio::task::yield_now().await; + } + }) + .await + .expect("peer close action should be applied"); + assert_eq!(callbacks.load(Ordering::SeqCst), 1); + assert!( + vm.waiting_host_op_id().is_some(), + "driver should be suspended after applying the peer-close action" + ); + assert!( + !server.is_finished(), + "server must retain the unresolved close handshake" + ); + tokio::time::sleep(Duration::from_millis(120)).await; + + let repolled_at = tokio::time::Instant::now(); + let error = poll_websocket_once(&mut vm) + .await + .expect_err("the peer-close transition deadline should already be expired"); + assert!( + error.to_string().contains("close handshake timed out"), + "{error}" + ); + assert!( + repolled_at.elapsed() < Duration::from_millis(30), + "delayed peer-close repoll must not receive a fresh timeout" + ); + assert_eq!(callbacks.load(Ordering::SeqCst), 1); + release_server + .send(()) + .expect("server should still await release"); + server.await.expect("server should finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_script_timeout_expires_during_periodic_active_traffic() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + for index in 0..10 { + if socket + .send(Message::text(format!("item-{index}"))) + .await + .is_err() + { + break; + } + tokio::time::sleep(Duration::from_millis(30)).await; + } + }); + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ {{ action: "continue" }} }} + http::client::websocket({{ + url: "ws://{address}/", + timeout_ms: 110 + }}, callback); + "# + ); + let mut config = websocket_config(address.port()); + config.max_stream_duration = Duration::from_secs(2); + config.stream_idle_timeout = Duration::from_millis(250); + let started = tokio::time::Instant::now(); + let error = match run_websocket(&source, config).await { + Ok(_) => panic!("total deadline must terminate active traffic"), + Err(error) => error, + }; + let elapsed = started.elapsed(); + assert!(error.to_string().contains("deadline exceeded"), "{error}"); + assert!(elapsed >= Duration::from_millis(80), "elapsed {elapsed:?}"); + assert!(elapsed < Duration::from_millis(400), "elapsed {elapsed:?}"); + server.await.expect("server should finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn websocket_invalid_timeout_is_rejected_before_permit_or_socket_admission() { + for timeout in ["0", "-1", "\"wrong\""] { + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ {{ action: "stop" }} }} + http::client::websocket({{ + url: "ws://127.0.0.1:1/", + timeout_ms: {timeout} + }}, callback); + "# + ); + let compiled = compile_source(&source).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_http(websocket_config(1)) + .expect("configuration should install"); + vm.set_http_max_in_flight(0); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("host functions should bind"); + let error = vm.run().expect_err("invalid timeout must fail"); + assert!(error.to_string().contains("timeout_ms"), "{error}"); + assert!( + !error.to_string().contains("in-flight request limit"), + "timeout validation must precede permit admission: {error}" + ); + } +} + +#[test] +fn websocket_callable_is_feature_gated_with_typed_callback_metadata() { + assert!( + compile_source( + r#" + use http; + http::client::websocket( + { url: "ws://example.test/socket" }, + |item| 1 + ); + "#, + ) + .is_err(), + "wrong callback schema must fail before transport admission" + ); + let callable = default_host_callables() + .iter() + .find(|callable| callable.name == "http::client::websocket") + .expect("http-client must publish the WebSocket callable"); + assert_eq!(callable.signature.params.len(), 2); + assert_eq!(callable.signature.params[0].ty.display_label(), "map"); + assert_eq!( + callable.signature.params[1].ty.display_label(), + "fn(map) -> map" + ); + assert_eq!(callable.signature.return_type, "map"); + + compile_source( + r#" + use http; + fn on_socket(item: map) -> map { { action: "stop" } } + http::client::websocket({ url: "ws://example.test/socket" }, on_socket); + "#, + ) + .expect("typed WebSocket callback should compile"); +} + +#[test] +fn websocket_admission_and_permit_reuse_do_not_require_a_tokio_reactor() { + let source = r#" + use http; + http::client::websocket( + { url: "ws://127.0.0.1:1/socket" }, + |item| { action: "continue" } + ); + "#; + let compiled = compile_source(source).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + vm.set_http_max_in_flight(1); + vm.configure_http(websocket_config(1)) + .expect("configuration should install"); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("host functions should bind"); + + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + vm.reset_for_reuse(); + assert!(matches!(vm.run().unwrap(), VmStatus::Waiting(_))); + vm.reset_for_reuse(); +} + +#[tokio::test(flavor = "current_thread")] +async fn handshake_preserves_host_headers_and_selected_protocol_then_stop_drops_socket() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let observed = Arc::new(Mutex::new(None)); + let observed_request = Arc::clone(&observed); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + #[allow(clippy::result_large_err)] + let callback = move |request: &Request, mut response: Response| { + *observed_request.lock().expect("request lock") = Some(( + request.headers()["host"].to_str().unwrap().to_string(), + request.headers()["x-test"].to_str().unwrap().to_string(), + request.headers()["sec-websocket-protocol"] + .to_str() + .unwrap() + .to_string(), + )); + response.headers_mut().insert( + "sec-websocket-protocol", + "chat.v2".parse().expect("valid protocol"), + ); + Ok(response) + }; + let mut socket = tokio_tungstenite::accept_hdr_async(stream, callback) + .await + .expect("handshake should succeed"); + let terminal = tokio::time::timeout(Duration::from_millis(500), socket.next()) + .await + .expect("client should drop promptly"); + assert!(terminal.is_none() || terminal.is_some_and(|item| item.is_err())); + }); + let source = format!( + r#" + use http; + use bytes; + fn callback(item: map) -> map {{ + assert(item["kind"] == "open"); + assert(item["status"] == 101); + assert(item["protocol"] == "chat.v2"); + {{ action: "stop" }} + }} + http::client::websocket({{ + url: "ws://{address}/socket?q=1", + headers: {{ "x-test": "present" }}, + protocols: ["chat.v1", "chat.v2"] + }}, callback); + "# + ); + let vm = run_websocket(&source, websocket_config(address.port())) + .await + .expect("WebSocket should stop after open"); + server.await.expect("server should finish"); + assert_eq!( + map_field(&vm.stack()[0], "outcome"), + &Value::string("stopped") + ); + assert_eq!(map_field(&vm.stack()[0], "items"), &Value::Int(1)); + assert_eq!( + observed.lock().expect("request lock").as_ref().unwrap(), + &( + format!("127.0.0.1:{}", address.port()), + "present".to_string(), + "chat.v1, chat.v2".to_string() + ) + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn callback_actions_are_applied_before_next_message_and_close_handshake_completes() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + assert_eq!( + socket + .next() + .await + .expect("text message") + .expect("valid message"), + Message::text("hello") + ); + socket + .send(Message::binary(vec![1, 2, 3])) + .await + .expect("binary should send"); + assert_eq!( + socket.next().await.expect("pong").expect("valid pong"), + Message::Ping(vec![9].into()) + ); + socket + .send(Message::Pong(vec![9].into())) + .await + .expect("pong should send"); + socket + .send(Message::text("done")) + .await + .expect("text should send"); + let close = socket + .next() + .await + .expect("close message") + .expect("valid close"); + let Message::Close(Some(frame)) = close else { + panic!("expected close frame, got {close:?}"); + }; + assert_eq!(u16::from(frame.code), 1000); + assert_eq!(frame.reason, "complete"); + socket + .flush() + .await + .expect("close acknowledgment should flush"); + }); + let source = format!( + r#" + use http; + use bytes; + fn callback(item: map) -> map {{ + let action = if item["kind"] == "open" => {{ + {{ action: "send_text", text: "hello", data: bytes::from_array_u8([]), code: 1000, reason: "" }} + }} else if item["kind"] == "binary" => {{ + assert(item["data"] == bytes::from_array_u8([1, 2, 3])); + {{ action: "ping", text: "", data: bytes::from_array_u8([9]), code: 1000, reason: "" }} + }} else if item["kind"] == "pong" => {{ + assert(item["data"] == bytes::from_array_u8([9])); + {{ action: "continue", text: "", data: bytes::from_array_u8([]), code: 1000, reason: "" }} + }} else if item["kind"] == "text" => {{ + assert(item["text"] == "done"); + {{ action: "close", text: "", data: bytes::from_array_u8([]), code: 1000, reason: "complete" }} + }} else => {{ + {{ action: "continue", text: "", data: bytes::from_array_u8([]), code: 1000, reason: "" }} + }}; + action + }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let vm = run_websocket(&source, websocket_config(address.port())) + .await + .expect("WebSocket close handshake should complete"); + server.await.expect("server should finish"); + assert_eq!( + map_field(&vm.stack()[0], "outcome"), + &Value::string("closed") + ); + assert_eq!(map_field(&vm.stack()[0], "items"), &Value::Int(4)); + assert_eq!(map_field(&vm.stack()[0], "bytes_received"), &Value::Int(7)); + assert_eq!(map_field(&vm.stack()[0], "bytes_sent"), &Value::Int(5)); +} + +#[tokio::test(flavor = "current_thread")] +async fn local_close_discards_pipelined_messages_until_peer_acknowledgment() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let (close_received, mut close_received_rx) = tokio::sync::oneshot::channel(); + let (release_acknowledgment, release_acknowledgment_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + socket + .send(Message::text("close-now")) + .await + .expect("trigger message should send"); + socket + .send(Message::text("discard-after-local-close")) + .await + .expect("pipelined message should send"); + let close = socket + .next() + .await + .expect("client should send close") + .expect("client close should be valid"); + assert!(matches!(close, Message::Close(_))); + close_received + .send(()) + .expect("client should await the peer acknowledgment"); + release_acknowledgment_rx + .await + .expect("test should release the peer acknowledgment"); + socket + .flush() + .await + .expect("close acknowledgment should flush"); + }); + let source = format!( + r#" + use http; + fn count_close_callback() -> bool; + fn callback(item: map) -> map {{ + assert(count_close_callback()); + if item["kind"] == "text" => {{ + assert(item["text"] == "close-now"); + {{ action: "close", code: 1000, reason: "done" }} + }} else => {{ + {{ action: "continue" }} + }} + }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let compiled = compile_source(&source).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_http(websocket_config(address.port())) + .expect("configuration should install"); + let callbacks = Arc::new(AtomicUsize::new(0)); + let mut registry = HostFunctionRegistry::new(); + registry.register_stack("count_close_callback", 0, { + let callbacks = Arc::clone(&callbacks); + move || { + Box::new(CountCloseCallbacks { + calls: Arc::clone(&callbacks), + }) + } + }); + registry + .bind_vm_cached(&mut vm) + .expect("imports should bind"); + assert!(matches!( + vm.run().expect("WebSocket should start"), + VmStatus::Waiting(_) + )); + tokio::time::timeout(Duration::from_millis(500), async { + loop { + match close_received_rx.try_recv() { + Ok(()) => break, + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => { + if poll_websocket_once(&mut vm) + .await + .expect("WebSocket operation should progress") + { + assert!(matches!( + vm.resume().expect("WebSocket callback should resume"), + VmStatus::Waiting(_) + )); + } + tokio::task::yield_now().await; + } + Err(tokio::sync::oneshot::error::TryRecvError::Closed) => { + panic!("server closed before receiving the client close") + } + } + } + }) + .await + .expect("client close should reach the server"); + assert_eq!(callbacks.load(Ordering::SeqCst), 2); + assert!(vm.waiting_host_op_id().is_some()); + assert!(vm.stack().iter().all(|value| { + !matches!(value, Value::Map(map) if map.get(&Value::string("outcome")) == Some(&Value::string("closed"))) + })); + + release_acknowledgment + .send(()) + .expect("server should await acknowledgment release"); + drive_websocket(&mut vm) + .await + .expect("peer acknowledgment should complete the WebSocket"); + server.await.expect("server should finish"); + assert_eq!(callbacks.load(Ordering::SeqCst), 2); + assert_eq!( + map_field(&vm.stack()[0], "outcome"), + &Value::string("closed") + ); + assert_eq!(map_field(&vm.stack()[0], "items"), &Value::Int(2)); + assert_eq!(map_field(&vm.stack()[0], "bytes_received"), &Value::Int(9)); + assert_eq!(map_field(&vm.stack()[0], "bytes_sent"), &Value::Int(0)); +} + +#[tokio::test(flavor = "current_thread")] +async fn control_frames_do_not_consume_an_exhausted_application_byte_budget() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + socket + .send(Message::text("full")) + .await + .expect("application payload should send"); + assert_eq!( + socket.next().await.expect("ping").expect("valid ping"), + Message::Ping(vec![7, 8, 9].into()) + ); + socket + .send(Message::Pong(vec![7, 8, 9].into())) + .await + .expect("pong should send"); + socket + .send(Message::Close(Some( + tokio_tungstenite::tungstenite::protocol::CloseFrame { + code: 1000.into(), + reason: "done".into(), + }, + ))) + .await + .expect("close should send"); + assert!(matches!( + socket.next().await.expect("close acknowledgment"), + Ok(Message::Close(_)) + )); + }); + let source = format!( + r#" + use http; + use bytes; + fn callback(item: map) -> map {{ + if item["kind"] == "text" => {{ + {{ action: "ping", data: bytes::from_array_u8([7, 8, 9]) }} + }} else => {{ + {{ action: "continue" }} + }} + }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let mut config = websocket_config(address.port()); + config.max_stream_total_bytes = 4; + let vm = run_websocket(&source, config) + .await + .expect("control traffic after the exact application limit should complete"); + server.await.expect("server should finish"); + assert_eq!( + map_field(&vm.stack()[0], "outcome"), + &Value::string("closed") + ); + assert_eq!(map_field(&vm.stack()[0], "bytes_received"), &Value::Int(4)); + assert_eq!(map_field(&vm.stack()[0], "bytes_sent"), &Value::Int(0)); +} + +#[tokio::test(flavor = "current_thread")] +async fn application_payload_one_byte_over_the_total_budget_is_rejected() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + socket + .send(Message::text("limit")) + .await + .expect("application payload should send"); + let terminal = tokio::time::timeout(Duration::from_millis(500), socket.next()) + .await + .expect("client should terminate promptly"); + assert!(terminal.is_none() || terminal.is_some_and(|item| item.is_err())); + }); + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ {{ action: "continue" }} }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let mut config = websocket_config(address.port()); + config.max_stream_total_bytes = 4; + let error = match run_websocket(&source, config).await { + Ok(_) => panic!("application payload above the total budget must fail"), + Err(error) => error, + }; + assert!(error.to_string().contains("total byte limit")); + server.await.expect("server should finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn fragmented_text_is_reassembled_before_callback_delivery() { + use tokio_tungstenite::tungstenite::protocol::frame::Frame; + use tokio_tungstenite::tungstenite::protocol::frame::coding::{Data, OpCode}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + socket + .send(Message::Frame(Frame::message( + "hel", + OpCode::Data(Data::Text), + false, + ))) + .await + .expect("first fragment should send"); + socket + .send(Message::Frame(Frame::message( + "lo", + OpCode::Data(Data::Continue), + true, + ))) + .await + .expect("last fragment should send"); + let terminal = socket.next().await.expect("client should close"); + assert!(matches!(terminal, Ok(Message::Close(_)))); + socket.flush().await.expect("close response should flush"); + }); + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ + if item["kind"] == "text" {{ assert(item["text"] == "hello"); }} + let action = if item["kind"] == "text" => {{ + {{ action: "close", code: 1000, reason: "done" }} + }} else => {{ + {{ action: "continue", code: 1000, reason: "" }} + }}; + action + }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let vm = run_websocket(&source, websocket_config(address.port())) + .await + .expect("fragmented text should complete"); + server.await.expect("server should finish"); + assert_eq!(map_field(&vm.stack()[0], "items"), &Value::Int(2)); +} + +#[tokio::test(flavor = "current_thread")] +async fn peer_close_is_delivered_once_and_continue_acknowledges_it() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + socket + .send(Message::Close(Some( + tokio_tungstenite::tungstenite::protocol::CloseFrame { + code: 1001.into(), + reason: "away".into(), + }, + ))) + .await + .expect("peer close should send"); + let terminal = socket + .next() + .await + .expect("client should acknowledge close"); + assert!(matches!(terminal, Ok(Message::Close(_)))); + }); + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ + if item["kind"] == "close" {{ + assert(item["code"] == 1001); + assert(item["reason"] == "away"); + }} + {{ action: "continue" }} + }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let vm = run_websocket(&source, websocket_config(address.port())) + .await + .expect("peer close should complete"); + server.await.expect("server should finish"); + assert_eq!( + map_field(&vm.stack()[0], "outcome"), + &Value::string("closed") + ); + assert_eq!(map_field(&vm.stack()[0], "items"), &Value::Int(2)); +} + +#[tokio::test(flavor = "current_thread")] +async fn peer_close_callback_close_sends_the_supplied_acknowledgment() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + socket + .send(Message::Close(Some( + tokio_tungstenite::tungstenite::protocol::CloseFrame { + code: 1001.into(), + reason: "away".into(), + }, + ))) + .await + .expect("peer close should send"); + let acknowledgment = socket + .next() + .await + .expect("client should acknowledge close") + .expect("close acknowledgment should be valid"); + let Message::Close(Some(frame)) = acknowledgment else { + panic!("expected close acknowledgment, got {acknowledgment:?}"); + }; + assert_eq!(u16::from(frame.code), 1000); + assert_eq!(frame.reason, "validated"); + }); + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ + if item["kind"] == "close" => {{ + assert(item["code"] == 1001); + assert(item["reason"] == "away"); + {{ action: "close", code: 1000, reason: "validated" }} + }} else => {{ + {{ action: "continue", code: 1000, reason: "" }} + }} + }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let vm = run_websocket(&source, websocket_config(address.port())) + .await + .expect("peer close callback close should complete without a host write error"); + server.await.expect("server should finish"); + assert_eq!( + map_field(&vm.stack()[0], "outcome"), + &Value::string("closed") + ); + assert_eq!(map_field(&vm.stack()[0], "items"), &Value::Int(2)); + assert_eq!(map_field(&vm.stack()[0], "bytes_received"), &Value::Int(0)); + assert_eq!(map_field(&vm.stack()[0], "bytes_sent"), &Value::Int(0)); +} + +#[tokio::test(flavor = "current_thread")] +async fn peer_close_callback_code_1014_is_emitted_on_the_wire() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let captured = Arc::new(Mutex::new(Vec::new())); + let capture_enabled = Arc::new(AtomicBool::new(false)); + let server_captured = Arc::clone(&captured); + let server_capture_enabled = Arc::clone(&capture_enabled); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let io = CaptureReads { + inner: stream, + enabled: Arc::clone(&server_capture_enabled), + bytes: Arc::clone(&server_captured), + }; + let mut socket = tokio_tungstenite::accept_async(io) + .await + .expect("handshake should succeed"); + server_capture_enabled.store(true, Ordering::SeqCst); + socket + .send(Message::Close(Some( + tokio_tungstenite::tungstenite::protocol::CloseFrame { + code: 1001.into(), + reason: "away".into(), + }, + ))) + .await + .expect("peer close should send"); + let _ = socket.next().await; + }); + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ + if item["kind"] == "close" => {{ + {{ action: "close", code: 1014, reason: "代理错误" }} + }} else => {{ + {{ action: "continue" }} + }} + }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let vm = run_websocket(&source, websocket_config(address.port())) + .await + .expect("code 1014 close acknowledgment should complete"); + server.await.expect("server should finish"); + + assert_eq!( + decode_masked_close(&captured.lock().unwrap()), + (1014, "代理错误".to_string()) + ); + assert_eq!( + map_field(&vm.stack()[0], "outcome"), + &Value::string("closed") + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn invalid_action_is_reported_and_connection_is_dropped() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + let terminal = tokio::time::timeout(Duration::from_millis(500), socket.next()) + .await + .expect("client should drop promptly"); + assert!(terminal.is_none() || terminal.is_some_and(|item| item.is_err())); + }); + let source = format!( + r#" + use http; + use bytes; + fn callback(item: map) -> map {{ {{ action: "send_binary", data: "wrong" }} }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let error = match run_websocket(&source, websocket_config(address.port())).await { + Ok(_) => panic!("invalid action payload must fail"), + Err(error) => error, + }; + assert!(error.to_string().contains("WebSocket send_binary action")); + server.await.expect("server should finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn ping_callback_rejects_application_data_action() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + socket + .send(Message::Ping(vec![1].into())) + .await + .expect("ping should send"); + let terminal = tokio::time::timeout(Duration::from_millis(500), socket.next()) + .await + .expect("client should terminate promptly"); + assert!(terminal.is_none() || terminal.is_some_and(|item| item.is_err())); + }); + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ + let action = if item["kind"] == "ping" => {{ + {{ action: "send_text", text: "forbidden" }} + }} else => {{ + {{ action: "continue" }} + }}; + action + }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let error = match run_websocket(&source, websocket_config(address.port())).await { + Ok(_) => panic!("ping application data send must fail"), + Err(error) => error, + }; + assert!(error.to_string().contains("invalid for the current item")); + server.await.expect("server should finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn idle_timeout_and_abnormal_eof_are_host_errors() { + for abnormal_eof in [false, true] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + if abnormal_eof { + drop(socket); + } else { + tokio::time::sleep(Duration::from_millis(200)).await; + } + }); + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ {{ action: "continue" }} }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let mut config = websocket_config(address.port()); + config.stream_idle_timeout = Duration::from_millis(30); + let error = match run_websocket(&source, config).await { + Ok(_) => panic!("terminal transport condition must fail"), + Err(error) => error, + }; + if abnormal_eof { + assert!( + error.to_string().contains("without a close handshake") + || error.to_string().contains("receive failed"), + "unexpected error: {error}" + ); + } else { + assert!(error.to_string().contains("idle timeout")); + } + server.await.expect("server should finish"); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn unoffered_selected_protocol_is_rejected() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + #[allow(clippy::result_large_err)] + let callback = |_request: &Request, mut response: Response| { + response.headers_mut().insert( + "sec-websocket-protocol", + "unoffered".parse().expect("valid protocol"), + ); + Ok(response) + }; + let _ = tokio_tungstenite::accept_hdr_async(stream, callback).await; + }); + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ {{ action: "stop" }} }} + http::client::websocket({{ + url: "ws://{address}/", + protocols: ["offered"] + }}, callback); + "# + ); + let error = match run_websocket(&source, websocket_config(address.port())).await { + Ok(_) => panic!("unoffered protocol must fail"), + Err(error) => error, + }; + assert!( + error.to_string().contains("invalid subprotocol") + || error.to_string().contains("unoffered protocol"), + "unexpected error: {error}" + ); + server.await.expect("server should finish"); +} + +#[tokio::test(flavor = "current_thread")] +async fn local_close_uses_close_timeout_instead_of_idle_timeout() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("client should connect"); + let mut socket = tokio_tungstenite::accept_async(stream) + .await + .expect("handshake should succeed"); + let close = socket.next().await.expect("client should send close"); + assert!(matches!(close, Ok(Message::Close(_)))); + tokio::time::sleep(Duration::from_millis(150)).await; + }); + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ + {{ action: "close", code: 1000, reason: "done" }} + }} + http::client::websocket({{ url: "ws://{address}/" }}, callback); + "# + ); + let mut config = websocket_config(address.port()); + config.stream_idle_timeout = Duration::from_millis(10); + config.websocket_close_timeout = Duration::from_millis(200); + config.max_stream_duration = Duration::from_millis(60); + let error = match run_websocket(&source, config).await { + Ok(_) => panic!("missing close acknowledgment must time out"), + Err(error) => error, + }; + assert!(error.to_string().contains("deadline exceeded")); + server.await.expect("server should finish"); +} + +#[test] +fn request_validation_rejects_managed_headers_before_connection() { + let cases = [ + "host", + "upgrade", + "connection", + "sec-websocket-key", + "sec-websocket-protocol", + ]; + for header in cases { + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ {{ action: "stop" }} }} + http::client::websocket({{ + url: "ws://127.0.0.1:9/", + headers: {{ "{header}": "forbidden" }} + }}, callback); + "# + ); + let compiled = compile_source(&source).expect("source should compile"); + let mut vm = Vm::new(compiled.program); + vm.configure_http(websocket_config(9)).unwrap(); + HostFunctionRegistry::new().bind_vm_cached(&mut vm).unwrap(); + let error = vm + .run() + .expect_err("managed header must fail before connect"); + assert!(error.to_string().contains("managed by the client")); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn timeout_ms_bounds_the_opening_handshake() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let server = tokio::spawn(async move { + let (_stream, _) = listener.accept().await.expect("client should connect"); + tokio::time::sleep(Duration::from_millis(300)).await; + }); + let source = format!( + r#" + use http; + fn callback(item: map) -> map {{ {{ action: "stop" }} }} + http::client::websocket({{ + url: "ws://{address}/", + timeout_ms: 60 + }}, callback); + "# + ); + let mut config = websocket_config(address.port()); + config.connect_timeout = Duration::from_secs(1); + config.max_stream_duration = Duration::from_secs(2); + let started = tokio::time::Instant::now(); + let error = match run_websocket(&source, config).await { + Ok(_) => panic!("opening handshake must be bounded by timeout_ms"), + Err(error) => error, + }; + let elapsed = started.elapsed(); + assert!(error.to_string().contains("deadline"), "{error}"); + assert!(elapsed >= Duration::from_millis(40), "elapsed {elapsed:?}"); + assert!(elapsed < Duration::from_millis(250), "elapsed {elapsed:?}"); + server.await.expect("server should finish"); +}