diff --git a/Cargo.lock b/Cargo.lock index b99df352..64ad2d98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -29,6 +41,12 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "base64" version = "0.22.1" @@ -56,6 +74,22 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -68,6 +102,23 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -77,6 +128,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "cranelift-assembler-x64" version = "0.129.1" @@ -243,6 +303,17 @@ version = "0.129.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d953932541249c91e3fa70a75ff1e52adc62979a2a8132145d4b9b3e6d1a9b6a" +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "endian-type" version = "0.1.2" @@ -271,6 +342,18 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fd-lock" version = "4.0.4" @@ -282,6 +365,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fnv" version = "1.0.7" @@ -294,6 +383,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -305,9 +403,81 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +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", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core", + "wasm-bindgen", +] [[package]] name = "gimli" @@ -321,6 +491,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -336,6 +515,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -351,6 +539,207 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "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]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -361,12 +750,29 @@ 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" @@ -385,18 +791,41 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "log" 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" @@ -412,6 +841,17 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nibble_vec" version = "0.1.0" @@ -429,10 +869,16 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags 2.11.0", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.1.1", "libc", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "paste" version = "1.0.15" @@ -446,7 +892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9676d58588b220f7af69d7aa86108042d2acaf21dd24c641a6d9ef3c4e193ba" dependencies = [ "pd-host-function 0.22.2", - "syn", + "syn 2.0.117", ] [[package]] @@ -455,7 +901,7 @@ version = "0.1.0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -466,7 +912,7 @@ checksum = "d9c941589fbbb839a40f7b80595d7b8f3742a8811268d787218f0c45c274d1f9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -480,18 +926,22 @@ dependencies = [ "cranelift-module", "cranelift-native", "futures-channel", + "futures-util", "libc", "paste", "pd-edge-abi", "pd-host-function 0.1.0", "regex", + "reqwest", "rt-format", + "rusqlite", "rustyline", "self_cell", "serde", "serde_json", - "syn", + "syn 2.0.117", "tokio", + "url", "windows-sys 0.59.0", ] @@ -512,12 +962,33 @@ dependencies = [ "serde_json", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -528,22 +999,110 @@ dependencies = [ ] [[package]] -name = "quote" -version = "1.0.44" +name = "quinn" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ - "proc-macro2", + "bytes", + "cfg_aliases 0.2.2", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", ] [[package]] -name = "radix_trie" -version = "0.2.1" +name = "quinn-proto" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ - "endian-type", - "nibble_vec", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", ] [[package]] @@ -601,6 +1160,61 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rt-format" version = "0.3.1" @@ -611,6 +1225,20 @@ dependencies = [ "regex", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -630,6 +1258,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustscript" version = "0.1.0" @@ -637,6 +1300,12 @@ 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" @@ -659,6 +1328,12 @@ 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" @@ -692,7 +1367,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -708,18 +1383,68 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -731,20 +1456,102 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "target-lexicon" version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -755,9 +1562,102 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +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", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "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", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -776,12 +1676,131 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +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" @@ -803,6 +1822,35 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -900,6 +1948,115 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 5cfc571d..f7a34ef5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ name = "vm" [features] default = ["runtime", "cli", "cranelift-jit"] runtime = [] +async = ["runtime", "dep:reqwest", "dep:url", "dep:tokio", "dep:futures-util"] +http-client = ["async"] +sqlite = ["runtime", "dep:rusqlite"] edge-abi = [ "dep:edge_abi", "edge_abi/console", @@ -60,6 +63,11 @@ 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 } +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 } edge_abi = { package = "pd-edge-abi", version = "0.1.1", default-features = false, optional = true } futures-channel = "0.3" paste = "1" @@ -77,6 +85,7 @@ windows-sys = { version = "0.59", features = ["Win32_System_Diagnostics_Debug", libc = "0.2" [dev-dependencies] +futures-util = "0.3" syn = { version = "2", features = ["full"] } tokio = { version = "1", features = ["macros", "rt", "time", "sync"] } @@ -85,5 +94,15 @@ name = "host_binding_generation_tests" path = "tests/host_binding_generation_tests.rs" required-features = ["cranelift-jit"] +[[test]] +name = "http_host_tests" +path = "tests/vm/http_host_tests.rs" +required-features = ["runtime", "http-client"] + +[[test]] +name = "sqlite_host_tests" +path = "tests/vm/sqlite_host_tests.rs" +required-features = ["sqlite"] + [build-dependencies] syn = { version = "2", features = ["full"] } diff --git a/build.rs b/build.rs index 09cfa5ac..ce572c4f 100644 --- a/build.rs +++ b/build.rs @@ -115,6 +115,7 @@ struct CallableDecl { wrapper: Option, host_binding_kind: HostBindingKind, host_execution: HostExecutionKind, + runtime_owned_pending: bool, } #[derive(Clone, Debug)] @@ -149,11 +150,32 @@ fn main() { println!("cargo:rerun-if-changed={}", catalog_path.display()); let catalog = parse_catalog(&catalog_path); - let host_sources = [SourceSpec { - path: "src/builtins/runtime/host.rs".to_string(), - module: "host".to_string(), - category: SourceCategory::DefaultHost, - }]; + let mut host_sources = vec![ + SourceSpec { + path: "src/builtins/runtime/host.rs".to_string(), + module: "host".to_string(), + category: SourceCategory::DefaultHost, + }, + SourceSpec { + path: "src/builtins/runtime/context_host.rs".to_string(), + module: "context_host".to_string(), + category: SourceCategory::DefaultHost, + }, + ]; + if env::var_os("CARGO_FEATURE_ASYNC").is_some() { + host_sources.push(SourceSpec { + path: "src/builtins/runtime/http.rs".to_string(), + module: "http".to_string(), + category: SourceCategory::DefaultHost, + }); + } + if env::var_os("CARGO_FEATURE_SQLITE").is_some() { + host_sources.push(SourceSpec { + path: "src/builtins/runtime/sqlite.rs".to_string(), + module: "sqlite".to_string(), + category: SourceCategory::DefaultHost, + }); + } let builtin_sources = builtin_source_specs(&namespaces); let core_sources = [SourceSpec { path: "src/builtins/runtime/core.rs".to_string(), @@ -229,10 +251,21 @@ fn write_generated_file(path: &Path, contents: &str) { fn builtin_source_specs(namespaces: &[NamespaceDecl]) -> Vec { namespaces .iter() - .map(|namespace| SourceSpec { - path: format!("src/builtins/runtime/{}.rs", namespace.module), - module: namespace.module.clone(), - category: SourceCategory::NamespacedBuiltin, + .map(|namespace| { + let path = if namespace.module == "io" { + if cfg!(feature = "async") { + "src/builtins/runtime/io/async_io.rs".to_string() + } else { + "src/builtins/runtime/io/blocking.rs".to_string() + } + } else { + format!("src/builtins/runtime/{}.rs", namespace.module) + }; + SourceSpec { + path, + module: namespace.module.clone(), + category: SourceCategory::NamespacedBuiltin, + } }) .collect() } @@ -254,6 +287,9 @@ fn parse_sources( } pub(crate) fn classify_host_binding(function: &ItemFn) -> HostBindingKind { + if function.sig.asyncness.is_some() { + return HostBindingKind::StaticStack; + } if function.sig.inputs.iter().any(|input| match input { FnArg::Typed(pat_type) => is_vm_context_type(&pat_type.ty), _ => false, @@ -279,6 +315,9 @@ pub(crate) fn classify_host_binding(function: &ItemFn) -> HostBindingKind { } pub(crate) fn infer_host_execution(function: &ItemFn) -> HostExecutionKind { + if function.sig.asyncness.is_some() { + return HostExecutionKind::MaySuspend; + } let return_type = normalized_return_type(&function.sig.output); if contains_host_call_result(&return_type) { HostExecutionKind::MaySuspend @@ -425,6 +464,8 @@ fn parse_source_file(path: &Path, spec: &SourceSpec, _order_offset: usize) -> Ve wrapper, host_binding_kind: classify_host_binding(function), host_execution: infer_host_execution(function), + runtime_owned_pending: function.sig.asyncness.is_none() + && contains_host_call_result(&normalized_return_type(&function.sig.output)), }); } out @@ -934,6 +975,7 @@ fn render_builtin_catalog( writeln!(&mut out, "impl BuiltinFunction {{").unwrap(); render_builtin_name_method(&mut out, &builtin_variant_order, &actual_builtin_by_variant); + render_builtin_capability_method(&mut out, builtin_callables); render_builtin_arity_method(&mut out, &builtin_variant_order, &actual_builtin_by_variant); render_builtin_accepts_arity_method( &mut out, @@ -1075,6 +1117,14 @@ fn render_builtin_runtime_dispatch( ) .unwrap(); } + if callable.runtime_owned_pending { + writeln!( + &mut out, + " registry.mark_runtime_owned_pending({:?});", + callable.name + ) + .unwrap(); + } } writeln!(&mut out, "}}").unwrap(); writeln!(&mut out).unwrap(); @@ -1091,6 +1141,14 @@ fn render_builtin_runtime_dispatch( .render_bind_static_call(&callable.name, &host_wrapper_adapter_name(callable)); writeln!(&mut out, " {:?} => {{", callable.name).unwrap(); writeln!(&mut out, " {bind_call}").unwrap(); + if callable.runtime_owned_pending { + writeln!( + &mut out, + " vm.mark_runtime_owned_pending_binding({:?});", + callable.name + ) + .unwrap(); + } writeln!(&mut out, " true").unwrap(); writeln!(&mut out, " }}").unwrap(); } @@ -1408,6 +1466,35 @@ fn render_builtin_name_method( writeln!(out).unwrap(); } +fn render_builtin_capability_method(out: &mut String, builtin_callables: &[CallableDecl]) { + let mut capability_variants = Vec::new(); + for callable in builtin_callables { + let variant = builtin_variant_name(&callable.name); + if !capability_variants.contains(&variant) { + capability_variants.push(variant); + } + } + capability_variants.sort(); + writeln!(out, " #[cfg(feature = \"runtime\")]").unwrap(); + writeln!( + out, + " pub(crate) const fn requires_explicit_host_capability(self) -> bool {{" + ) + .unwrap(); + if capability_variants.is_empty() { + writeln!(out, " false").unwrap(); + } else { + let patterns = capability_variants + .iter() + .map(|variant| format!("BuiltinFunction::{variant}")) + .collect::>() + .join(" | "); + writeln!(out, " matches!(self, {patterns})").unwrap(); + } + writeln!(out, " }}").unwrap(); + writeln!(out).unwrap(); +} + fn render_builtin_arity_method( out: &mut String, builtin_variant_order: &[String], @@ -1830,6 +1917,9 @@ fn host_wrapper_adapter_name(callable: &CallableDecl) -> String { fn generated_wrapper_decl(function: &ItemFn) -> WrapperDecl { let mut params = Vec::new(); + if function.sig.asyncness.is_some() { + params.push(WrapperParamKind::Vm); + } for input in &function.sig.inputs { let FnArg::Typed(pat_type) = input else { panic!("methods are not supported in #[pd_host_function] declarations"); @@ -1856,6 +1946,13 @@ fn parse_callable_params(function: &ItemFn) -> Vec { let FnArg::Typed(pat_type) = input else { panic!("methods are not supported in #[pd_host_function] declarations"); }; + if pat_type + .attrs + .iter() + .any(|attr| attr.path().is_ident("pd_host_context")) + { + return None; + } if is_vm_context_type(&pat_type.ty) { return None; } @@ -2022,7 +2119,7 @@ fn type_label(ty: &Type) -> String { }; format!("{} | null", type_label(inner)) } - "VmResult" | "HostCallResult" => { + "VmResult" | "HostCallResult" | "HostFutureOutput" => { let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { panic!("{ident} requires one generic argument"); }; diff --git a/crates/rustscript/Cargo.toml b/crates/rustscript/Cargo.toml index 6279a1e1..8d72371c 100644 --- a/crates/rustscript/Cargo.toml +++ b/crates/rustscript/Cargo.toml @@ -16,6 +16,8 @@ runtime = ["pd_vm_crate/runtime"] edge-abi = ["pd_vm_crate/edge-abi"] cli = ["pd_vm_crate/cli"] cranelift-jit = ["pd_vm_crate/cranelift-jit"] +http-client = ["runtime", "pd_vm_crate/http-client"] +sqlite = ["pd_vm_crate/sqlite"] [dependencies] -pd_vm_crate = { package = "pd-vm", path = "../..", version = ">=0.1.0, <1.0.0" } +pd_vm_crate = { package = "pd-vm", path = "../..", version = "=0.1.0", default-features = false } diff --git a/crates/rustscript/tests/alias_smoke.rs b/crates/rustscript/tests/alias_smoke.rs index c58bb302..5d6f0494 100644 --- a/crates/rustscript/tests/alias_smoke.rs +++ b/crates/rustscript/tests/alias_smoke.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "sqlite")] +use rustscript::SqliteHostExt; + /// Verify that the `rustscript` alias crate re-exports the same API as `pd-vm`. #[test] fn alias_exports_compile_source() { @@ -21,3 +24,51 @@ fn alias_exports_op_code() { let _ = rustscript::OpCode::Nop; let _ = rustscript::OpCode::Add; } + +#[cfg(feature = "runtime")] +#[test] +fn alias_exports_public_invocation_stream_contract() { + fn accept_item(_item: rustscript::InvocationItem) {} + + accept_item(rustscript::InvocationItem::Complete( + rustscript::Value::Null, + )); + accept_item(rustscript::InvocationItem::Event(rustscript::Value::Bool( + true, + ))); + + fn accept_poll(_poll: rustscript::InvocationPoll) {} + accept_poll(rustscript::InvocationPoll::Pending); + accept_poll(rustscript::InvocationPoll::Ready(None)); + accept_poll(rustscript::InvocationPoll::Ready(Some(Ok( + rustscript::InvocationItem::Complete(rustscript::Value::Null), + )))); + + fn accept_error(_error: rustscript::InvocationError) {} + accept_error(rustscript::InvocationError::Cancelled( + rustscript::CancellationReason::Requested, + )); + accept_error(rustscript::InvocationError::Host { + message: "boom".to_string(), + }); +} + +#[cfg(feature = "http-client")] +#[test] +fn alias_http_client_includes_runtime_contract() { + fn accept_runtime_result(_result: rustscript::RuntimeResult<()>) {} + + accept_runtime_result(Ok(())); +} + +#[cfg(feature = "sqlite")] +#[test] +fn alias_exports_public_sqlite_configuration() { + let program = rustscript::compile_source("0;") + .expect("minimal alias SQLite program should compile") + .program; + let mut vm = rustscript::Vm::new(program); + vm.configure_sqlite(rustscript::SqlitePolicy::default()); + let _limits = rustscript::SqliteLimits::default(); + vm.clear_sqlite_configuration(); +} diff --git a/docs/callable-runtime.md b/docs/callable-runtime.md index ed0cfd4b..217c3668 100644 --- a/docs/callable-runtime.md +++ b/docs/callable-runtime.md @@ -1,15 +1,24 @@ # Script call frames and callable values -RustScript bytecode format version 11 (VMBC v11) introduces runtime script call frames, first-class callable values, and the static builtin ID catalog. +RustScript bytecode format version 12 (VMBC v12) carries runtime script call frames, first-class callable values, the static builtin ID catalog, and the direct script-call opcode. Version 11 introduced frames, callable values, and the static catalog; version 12 adds `callscript` for statically resolved named calls. ## Bytecode contract - `call ` remains the direct host/builtin operation; the `u16` operand is an explicit static builtin call index from the catalog (or a host-import slot) — never a count-derived offset. - `callvalue ` consumes a stack segment in `callee, arg0, ..., argN` order. +- `callscript ` calls a statically resolved named script function by prototype ID. It consumes only `argc` arguments; no callable value is taken from the stack, so environment-free named functions can be called without a hidden callable local. - callable environments are bound through the internal builtin call path; callable creation adds no bytecode opcode. - `ret` completes the active script frame. A nested frame leaves exactly one result at the caller segment base, using `null` when the body produced no value. Root `ret` keeps the historical program-result stack behavior. -VMBC v11 is a hard format boundary. Decoders reject all earlier versions (v10 and below) with a deterministic unsupported-version error; there is no compatibility decoder and no old-ID alias. The stream includes script-function entry ranges, callable prototypes, function regions, root callable bindings, and call indices drawn from the static builtin catalog. PDRC v6 recordings and AOT artifacts (format 7, ABI 6) use their corresponding bumped versions and include callable metadata in cache identity. +### Call ownership + +The three call opcodes differ in who owns the callee and what the frame must provide: + +- `call` — the callee is owned by the static builtin catalog (or the host-import slot). The frame contributes only `argc` arguments; there is no callable value anywhere in the program. +- `callvalue` — the callee is a `Value::Callable` owned by the caller operand stack at the call site, and remains the caller's responsibility after the call. This path carries environments, closures, and any callable whose identity or capture state is runtime-valued. +- `callscript` — the callee is owned by program callable metadata (the prototype table). The frame contributes only `argc` arguments and no callable value, but unlike `call` the callee is a script function rather than a builtin, so the call enters a new script frame with its own local base. + +VMBC v12 is a hard format boundary. Decoders reject all earlier versions (v11 and below) with a deterministic unsupported-version error; there is no compatibility decoder and no old-ID alias. The stream includes script-function entry ranges, callable prototypes, function regions, root callable bindings, and call indices drawn from the static builtin catalog. PDRC v6 recordings and AOT artifacts (format 8, ABI 8) use their corresponding bumped versions and include callable metadata in cache identity. ## Static builtin IDs @@ -18,7 +27,7 @@ Every VM-visible builtin (ordinary, internal, and special-call) has one explicit - **Immutable explicit IDs.** IDs never change once assigned. Adding or reordering catalog entries never renumbers existing entries; new builtins take the next free ID in their documented block (extension `0x0000..=0xFF8F` for future builtins and host imports, special-call `0xFF90..=0xFFA1`, ordinary `0xFFA2..=0xFFFF`). The reserved sentinel gap `0xFF90..=0xFF92` stays unassigned. - **Build-time validation.** The build fails on duplicate IDs, duplicate source names, duplicate Rust variants, out-of-block IDs, class/gate inconsistencies, a discovered runtime callable without an explicit ID, or a catalog entry without a runtime callable. - **Shared std/no-std IDs.** `pd-vm-nostd` dispatches on the same static indices through the checked-in generated mirror `pd-vm-nostd/src/generated_builtin_ids.rs`; the workspace test `static_builtin_ids_are_frozen` fails when the mirror drifts from the catalog. -- **One-time format break.** The static ID migration bumped VMBC to v11 (and the internal bytecode ABI to 11). Older VMBC versions are rejected, never decoded. +- **Format breaks are permanent.** The static ID migration bumped VMBC to v11 (and the internal bytecode ABI to 11); the `callscript` opcode break bumped both to v12. Versions below the current format are rejected, never decoded. ## Runtime model @@ -29,10 +38,24 @@ Each script invocation owns: - frame-local count; - active prototype and callable identity. -Arguments, captures, named callable bindings, and the self binding are installed before control moves to the function entry. Recursive calls therefore allocate independent local storage and are limited to 1,024 script frames. +Arguments, captures, hidden callable bindings for materialized named functions, and the self binding are installed before control moves to the function entry. Recursive calls therefore allocate independent local storage and are limited to 1,024 script frames. Branches are restricted to the active function region. Validation rejects cross-region targets before execution, and the interpreter repeats the check at runtime. +## Frame-local allocation and callable materialization + +Each script invocation frame is an independent local-address space with its own `local_base`. Locals that are live at the same time inside one frame interfere and receive distinct relative slot numbers; locals that belong to different frames never interfere and may reuse the same relative slot number, because the runtime frame bases already separate them. A statically resolved named call keeps the caller's argument slots and post-call values live in the caller frame, while the callee body's locals are analyzed inside the callee frame. + +Named functions receive a hidden callable slot only when runtime `Value::Callable` identity is actually required: + +- the function is exported under the `ExportedCallable { local_slot }` contract; +- the function is referenced as a value (stored, passed, or returned); +- the function captures an environment; +- a dynamic call site can target the function (invoked slot or argument flow into an invoked parameter); +- the function's runtime self identity is required by a capturing or dynamic recursion path. + +Functions that only receive plain direct calls — including non-capturing direct recursion — are lowered through `callscript` by prototype ID and consume no hidden callable local. The compiler reports the aggregate frame-local count (data slots plus materialized callable slots) in `FrameLocalLimitExceeded` diagnostics, so overflow reports real counts instead of a sentinel. Genuine same-frame pressure beyond 256 simultaneous locals keeps failing until wide local bytecode lands. + ## Callable identity and lifetime A callable contains its prototype ID, kind, and optional environment. The Program/Store owns the callable lifetime. Capture-free function items compare by prototype identity inside that Program; closures compare by runtime environment identity. Callable constants are forbidden; functions are initialized from Program metadata and closures are materialized at their declaration site. @@ -43,10 +66,22 @@ Reset clears Program runtime values and rebinds root function items from Program PDRC recordings preserve full execution-frame metadata. Callable environments use identity-table encoding, so aliases still share one environment after decode. +## Invocation item stream + +`Vm::start_invocation` starts one exported callable with ordinary `Value` arguments and returns an `Invocation` handle that behaves like a fused `Stream>`: + +- `InvocationItem::Event(value)` items arrive in order for each `stream::emit(value)` call; `stream::emit` still evaluates to `()` inside RSS. +- exactly one `InvocationItem::Complete(value)` carries the callable return value; events never replace it; +- cancellation, fuel exhaustion, epoch deadline expiry, runtime capability failures, and host failures each produce exactly one typed `InvocationError` item; +- every poll after `Complete` or the error item returns `Ready(None)` (fused end of stream); +- `InvocationPoll::Pending` means the VM is paused on an outstanding host operation; drive it through the embedding-owned async bridge and poll again. + +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. + ## Optimized backends -Whole-program AOT and Trace JIT use the same builtin call path (static catalog IDs) for environment binding and native frame dispatch for `callvalue`. Script-frame entry and return preserve frame-relative locals and typed continuations. +Whole-program AOT and Trace JIT use the same builtin call path (static catalog IDs) for environment binding, native frame dispatch for `callvalue`, and prototype-direct native dispatch for `callscript`. Script-frame entry and return preserve frame-relative locals and typed continuations. ## Embedded runtime -`pd-vm-nostd` decodes the same VMBC v11 callable metadata and executes callable binding, `callvalue`, recursive frames, captures, and direct host targets using `core` plus `alloc`, dispatching on the identical static builtin IDs via its checked-in generated mirror. +`pd-vm-nostd` decodes the same VMBC v12 callable metadata and executes callable binding, `callvalue`, `callscript`, recursive frames, captures, and direct host targets using `core` plus `alloc`, dispatching on the identical static builtin IDs via its checked-in generated mirror. diff --git a/pd-host-function/src/lib.rs b/pd-host-function/src/lib.rs index 4aa1d3bc..fb4f8f96 100644 --- a/pd-host-function/src/lib.rs +++ b/pd-host-function/src/lib.rs @@ -8,7 +8,9 @@ use syn::{ #[proc_macro_attribute] pub fn pd_host_function(attr: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(attr with Punctuated::::parse_terminated); - match expand_pd_host_function(args, parse_macro_input!(item as ItemFn)) { + let item = parse_macro_input!(item as ItemFn); + let result = expand_pd_host_function(args, item); + match result { Ok(tokens) => tokens.into(), Err(err) => err.to_compile_error().into(), } @@ -19,9 +21,20 @@ fn expand_pd_host_function( mut item: ItemFn, ) -> Result { parse_name_arg(&attr)?; + let is_async = item.sig.asyncness.is_some(); let docs = doc_string(&item.attrs); for input in &item.sig.inputs { - validate_param(input)?; + if is_async { + validate_async_param(input)?; + } else if is_host_context_param(input) { + return Err(Error::new_spanned( + input, + "#[pd_host_context] is only valid on async host functions", + )); + } + if !is_host_context_param(input) { + validate_param(input)?; + } } validate_return_type(&item.sig.output)?; @@ -39,13 +52,85 @@ fn expand_pd_host_function( if item.sig.ident != impl_name { item.sig.ident = impl_name.clone(); } - let wrapper = generate_vm_wrapper(&item, &wrapper_name)?; + let wrapper = if is_async { + generate_async_vm_wrapper(&item, &wrapper_name)? + } else { + generate_vm_wrapper(&item, &wrapper_name)? + }; + for input in &mut item.sig.inputs { + if let FnArg::Typed(pat_type) = input { + pat_type + .attrs + .retain(|attr| !attr.path().is_ident("pd_host_context")); + } + } Ok(quote! { #item #wrapper }) } +fn validate_async_param(arg: &FnArg) -> Result<(), Error> { + let FnArg::Typed(pat_type) = arg else { + return Err(Error::new_spanned(arg, "methods are not supported")); + }; + if is_vm_context_type(&pat_type.ty) { + return Err(Error::new_spanned( + &pat_type.ty, + "async host functions cannot borrow Vm; capture owned host context before submission", + )); + } + if is_host_context_param(arg) { + return Ok(()); + } + if !is_async_owned_type(&pat_type.ty) { + return Err(Error::new_spanned( + &pat_type.ty, + "async host function parameters must be owned and 'static", + )); + } + Ok(()) +} + +fn is_host_context_param(arg: &FnArg) -> bool { + match arg { + FnArg::Typed(pat_type) => pat_type + .attrs + .iter() + .any(|attr| attr.path().is_ident("pd_host_context")), + FnArg::Receiver(_) => false, + } +} + +fn is_async_owned_type(ty: &Type) -> bool { + match ty { + Type::Group(group) => is_async_owned_type(&group.elem), + Type::Paren(paren) => is_async_owned_type(&paren.elem), + Type::Reference(_) | Type::Slice(_) => false, + Type::Tuple(tuple) => tuple.elems.iter().all(is_async_owned_type), + Type::Path(path) => { + let Some(segment) = path.path.segments.last() else { + return false; + }; + if matches!( + segment.ident.to_string().as_str(), + "str" | "VmStringRef" | "VmBytesRef" | "VmArrayRef" | "VmMapRef" | "VmValueRef" + ) { + return false; + } + match &segment.arguments { + syn::PathArguments::None => true, + syn::PathArguments::AngleBracketed(args) => args.args.iter().all(|arg| match arg { + syn::GenericArgument::Type(inner) => is_async_owned_type(inner), + _ => false, + }), + syn::PathArguments::Parenthesized(_) => false, + } + } + _ => false, + } +} + fn parse_name_arg(args: &Punctuated) -> Result { let Some(Meta::NameValue(name_value)) = args.first() else { return Err(Error::new( @@ -223,19 +308,110 @@ fn generate_vm_wrapper( Ok(quote! { #[allow(dead_code)] - pub(super) fn #wrapper_name(#(#imm_wrapper_params),*) -> #wrapper_output { + pub(crate) fn #wrapper_name(#(#imm_wrapper_params),*) -> #wrapper_output { #(#imm_extract_stmts)* #call_expr } #[allow(dead_code)] - pub(super) fn #mutable_wrapper_name(#(#mut_wrapper_params),*) -> #wrapper_output { + pub(crate) fn #mutable_wrapper_name(#(#mut_wrapper_params),*) -> #wrapper_output { #(#mut_extract_stmts)* #call_expr } }) } +fn generate_async_vm_wrapper( + item: &ItemFn, + wrapper_name: &syn::Ident, +) -> Result { + let impl_name = &item.sig.ident; + let mutable_wrapper_name = syn::Ident::new(&format!("{wrapper_name}_mut"), wrapper_name.span()); + let mut extract_stmts = Vec::::new(); + let mut call_args = Vec::::new(); + let mut arg_index = 0usize; + + for input in &item.sig.inputs { + let FnArg::Typed(pat_type) = input else { + return Err(Error::new_spanned(input, "methods are not supported")); + }; + let Pat::Ident(PatIdent { ident, .. }) = pat_type.pat.as_ref() else { + return Err(Error::new_spanned( + &pat_type.pat, + "callable parameters must use identifier patterns", + )); + }; + let ty = &pat_type.ty; + if is_host_context_param(input) { + extract_stmts.push(quote! { + let #ident = <#ty as super::CaptureAsyncHostContext>::capture_with_args(vm, args)?; + }); + call_args.push(quote!(#ident)); + continue; + } + let label = LitStr::new( + &format!("{} {}", wrapper_name, ident), + proc_macro2::Span::call_site(), + ); + let index = syn::Index::from(arg_index); + extract_stmts.push(quote! { + let #ident = super::borrow_arg::<#ty>(args, #index, #label)?; + }); + call_args.push(quote!(#ident)); + arg_index += 1; + } + + let await_value = if return_is_vm_result(&item.sig.output) { + quote!(#impl_name(#(#call_args),*).await?) + } else { + quote!(#impl_name(#(#call_args),*).await) + }; + let future_result = if return_is_host_future_output(&item.sig.output) { + quote!(Ok(value.map(super::return_one))) + } else { + quote! { + match super::IntoHostCallOutcome::into_host_call_outcome(value) { + super::CallOutcome::Return(values) => { + Ok(super::HostFutureOutput::returning(values)) + } + super::CallOutcome::Pending(op_id) => Err(super::VmError::HostError( + format!("async host function returned nested pending operation {op_id}"), + )), + super::CallOutcome::Halt | super::CallOutcome::Yield => Err( + super::VmError::HostError( + "async host function returned a control-flow outcome".to_string(), + ), + ), + } + } + }; + let body = quote! { + #(#extract_stmts)* + vm.submit_host_future(Box::pin(async move { + let value = #await_value; + #future_result + })) + }; + + Ok(quote! { + #[allow(dead_code)] + pub(crate) fn #wrapper_name( + vm: &mut super::super::Vm, + args: &[super::super::Value], + ) -> super::super::VmResult { + #body + } + + #[allow(dead_code)] + pub(crate) fn #mutable_wrapper_name( + vm: &mut super::super::Vm, + args: &mut [super::super::Value], + ) -> super::super::VmResult { + #body + } + }) +} + fn wrapper_and_impl_names(name: &syn::Ident) -> (syn::Ident, syn::Ident) { let original = name.to_string(); match original.strip_suffix("_impl") { @@ -298,6 +474,20 @@ fn unwrap_vm_result_type(ty: &Type) -> Result, Error> { } } +fn return_is_host_future_output(output: &ReturnType) -> bool { + vm_result_inner_type(output) + .expect("pd_host_function return type should already be validated") + .and_then(|ty| match ty { + Type::Path(path) => path + .path + .segments + .last() + .map(|segment| segment.ident.clone()), + _ => None, + }) + .is_some_and(|ident| ident == "HostFutureOutput") +} + fn return_is_vm_result(output: &ReturnType) -> bool { vm_result_inner_type(output) .expect("pd_host_function return type should already be validated") @@ -359,7 +549,7 @@ fn type_label(ty: &Type) -> Result { let inner_label = type_label(inner)?; Ok(format!("{inner_label} | null")) } - "VmResult" | "HostCallResult" => { + "VmResult" | "HostCallResult" | "HostFutureOutput" => { let syn::PathArguments::AngleBracketed(args) = &segment.arguments else { return Err(Error::new_spanned( &segment.arguments, @@ -533,4 +723,60 @@ mod tests { .expect_err("the pd-host-function macro must not accept an async attribute"); assert!(error.to_string().contains("only supports name")); } + + #[test] + fn ordinary_async_signature_generates_host_driven_future_submission() { + let attr: Punctuated = parse_quote!(name = "test::async_call"); + let item: ItemFn = parse_quote!( + /// Returns an owned string asynchronously. + async fn async_call( + #[pd_host_context] context: TestContext, + value: String, + ) -> VmResult { + context.run(value).await + } + ); + let expanded = expand_pd_host_function(attr, item) + .expect("ordinary owned async function should use the generic async host contract") + .to_string(); + assert!(expanded.contains("submit_host_future")); + assert!(expanded.contains("async move")); + assert!(expanded.contains("borrow_arg")); + assert!(expanded.contains("CaptureAsyncHostContext")); + assert!(expanded.contains("capture_with_args")); + assert!(!expanded.contains("pd_host_context")); + } + + #[test] + fn async_host_future_output_maps_its_inner_value_to_call_return() { + let attr: Punctuated = parse_quote!(name = "test::completion"); + let item: ItemFn = parse_quote! { + /// Completes after mutating VM-owned state. + async fn completion() -> VmResult> { + todo!() + } + }; + + let expanded = expand_pd_host_function(attr, item) + .expect("host future output should be accepted") + .to_string(); + assert!(expanded.contains("value . map (super :: return_one)")); + } + + #[test] + fn async_signature_rejects_borrowed_parameters() { + let attr: Punctuated = parse_quote!(name = "test::borrowed"); + let item: ItemFn = parse_quote! { + async fn borrowed(value: &str) -> VmResult { + Ok(value.to_string()) + } + }; + + let error = expand_pd_host_function(attr, item).expect_err("borrow should be rejected"); + assert!( + error + .to_string() + .contains("parameters must be owned and 'static") + ); + } } diff --git a/pd-vm-nostd/README.md b/pd-vm-nostd/README.md index 59a30900..5361e776 100644 --- a/pd-vm-nostd/README.md +++ b/pd-vm-nostd/README.md @@ -6,7 +6,7 @@ compiler, parser, CLI, debugger, JIT/AOT backends, filesystem support, and opera ## Runtime surface -- VMBC v11 decoding with script-call and callable metadata +- VMBC v12 decoding with environment-free `CallScript` direct script calls alongside dynamic callable calls - stack, local, and recursive script-frame execution for direct bytecode opcodes - instruction fuel with pause/resume support - synchronous named host bindings and dynamic host dispatch diff --git a/pd-vm-nostd/src/error.rs b/pd-vm-nostd/src/error.rs index 35caec48..fe5c3210 100644 --- a/pd-vm-nostd/src/error.rs +++ b/pd-vm-nostd/src/error.rs @@ -14,6 +14,12 @@ pub enum VmError { InvalidCall(u16), InvalidCallable, InvalidCallablePrototype(u32), + /// Frame metadata (root binding slots, parameter or capture slots) + /// does not match the script frame layout. + InvalidFrameState(&'static str), + /// `CallScript` targeted a prototype whose capture layout requires an + /// environment; a static script call can never supply one. + CallScriptRequiresEnvironment(u32), CallStackOverflow, InvalidCallStackLimit(usize), InvalidCallArity { @@ -52,6 +58,11 @@ impl fmt::Display for VmError { Self::InvalidCallablePrototype(index) => { write!(f, "invalid callable prototype: {index}") } + Self::InvalidFrameState(detail) => write!(f, "invalid frame state: {detail}"), + Self::CallScriptRequiresEnvironment(prototype_id) => write!( + f, + "callscript prototype {prototype_id} requires a callable environment" + ), Self::CallStackOverflow => f.write_str("script call stack overflow"), Self::InvalidCallStackLimit(limit) => { write!( @@ -96,6 +107,22 @@ pub enum WireError { InvalidDebugFlag(u8), InvalidValueType(u8), InvalidCaptureBindingMode(u8), + /// `CallScript` referenced a prototype id that is out of range or does + /// not target a script function. + InvalidCallScriptTarget { + prototype_id: u32, + }, + /// `CallScript` declared an argc that disagrees with the prototype arity. + InvalidCallScriptArity { + prototype_id: u32, + expected: u8, + got: u8, + }, + /// An instruction operand is truncated by the end of the code blob. + TruncatedOperand { + opcode: u8, + expected_bytes: usize, + }, InvalidUtf8, LengthTooLarge(&'static str, usize), SchemaTooDeep, @@ -119,6 +146,25 @@ impl fmt::Display for WireError { Self::InvalidCaptureBindingMode(value) => { write!(f, "invalid capture binding mode: {value}") } + Self::InvalidCallScriptTarget { prototype_id } => write!( + f, + "callscript prototype {prototype_id} does not target a script function" + ), + Self::InvalidCallScriptArity { + prototype_id, + expected, + got, + } => write!( + f, + "callscript prototype {prototype_id} arity mismatch: expected {expected}, got {got}" + ), + Self::TruncatedOperand { + opcode, + expected_bytes, + } => write!( + f, + "truncated operand for opcode {opcode:#04x}: expected {expected_bytes} bytes" + ), Self::InvalidUtf8 => f.write_str("invalid UTF-8 in VMBC string"), Self::LengthTooLarge(field, length) => { write!(f, "{field} length is too large: {length}") diff --git a/pd-vm-nostd/src/program.rs b/pd-vm-nostd/src/program.rs index 5c511b0a..f0984807 100644 --- a/pd-vm-nostd/src/program.rs +++ b/pd-vm-nostd/src/program.rs @@ -230,6 +230,12 @@ pub enum OpCode { Not = 0x17, Lshr = 0x18, CallValue = 0x19, + /// Static direct script-function call: `prototype_id:u32 LE, argc:u8`. + /// + /// Mirrors the std ISA contract (opcode 0x1A, five operand bytes); the + /// decoder validates the target prototype and arity against the callable + /// metadata so an environment-free script call is a supported operation. + CallScript = 0x1A, } impl OpCode { @@ -238,6 +244,7 @@ impl OpCode { Self::Ldc | Self::Br | Self::Brfalse => 4, Self::Ldloc | Self::Stloc | Self::CallValue => 1, Self::Call => 3, + Self::CallScript => 5, _ => 0, } } @@ -274,6 +281,7 @@ impl TryFrom for OpCode { 0x17 => Ok(Self::Not), 0x18 => Ok(Self::Lshr), 0x19 => Ok(Self::CallValue), + 0x1a => Ok(Self::CallScript), _ => Err(()), } } diff --git a/pd-vm-nostd/src/vm.rs b/pd-vm-nostd/src/vm.rs index 0f7f73a4..35426100 100644 --- a/pd-vm-nostd/src/vm.rs +++ b/pd-vm-nostd/src/vm.rs @@ -238,21 +238,26 @@ impl Vm { Value::Null, ); for binding in self.program.root_callable_bindings() { - if let Some(binding_prototype) = self + // Mirror the interpreter's `enter_script_frame`: every + // root binding must fit the callee frame and reference a + // known prototype; a malformed program errors instead of + // silently skipping the slot. + let binding_prototype = self .program .callable_prototypes() .get(binding.prototype_id as usize) - { - let slot = binding.local_slot as usize; - if slot < prototype.frame_local_count { - self.locals[local_base + slot] = - Value::Callable(Rc::new(CallableValue { - prototype_id: binding.prototype_id, - kind: binding_prototype.kind, - env: None, - })); - } + .ok_or(VmError::InvalidCallablePrototype(binding.prototype_id))?; + let slot = binding.local_slot as usize; + if slot >= prototype.frame_local_count { + return Err(VmError::InvalidFrameState( + "root callable binding is outside the script frame", + )); } + self.locals[local_base + slot] = Value::Callable(Rc::new(CallableValue { + prototype_id: binding.prototype_id, + kind: binding_prototype.kind, + env: None, + })); } for (slot, value) in inherited { if slot < prototype.frame_local_count { @@ -299,6 +304,119 @@ impl Vm { } } + /// Execute a static `CallScript(prototype_id, argc)` instruction. + /// + /// Mirrors [`Self::call_value`] but resolves the callee from the static + /// prototype metadata: no runtime callable value exists, so + /// capture- or self-requiring prototypes fail with + /// [`VmError::CallScriptRequiresEnvironment`] and host-import prototypes + /// are never routed to the host path. + fn call_script(&mut self, prototype_id: u32, argc: u8) -> VmResult<()> { + // Mirror the interpreter contract: the operand underflow check comes + // before any prototype-driven rejection so a malformed call with a + // short stack reports `StackUnderflow`, not an environment error. + let operand_count = argc as usize; + if self.stack.len() < operand_count { + return Err(VmError::StackUnderflow); + } + let prototype = self + .program + .callable_prototypes() + .get(prototype_id as usize) + .cloned() + .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; + // A static script call can never supply a callable environment. + if !prototype.capture_slots.is_empty() || prototype.self_slot.is_some() { + return Err(VmError::CallScriptRequiresEnvironment(prototype_id)); + } + let stack_base = self.stack.len() - operand_count; + let operands = self.stack.split_off(stack_base); + if prototype.arity != argc || prototype.parameter_slots.len() != operands.len() { + return Err(VmError::InvalidCallArity { + import: String::from("script call"), + expected: prototype.arity, + got: argc, + }); + } + let CallableTarget::ScriptFunction(function_id) = prototype.target else { + // `CallScript` is a static script-function call and must never + // route a host-import prototype to the host path. + return Err(VmError::InvalidCallablePrototype(prototype_id)); + }; + if self.frames.len() >= self.max_script_call_depth { + return Err(VmError::CallStackOverflow); + } + let function = self + .program + .script_functions() + .get(function_id as usize) + .cloned() + .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; + let inherited = { + let base = self.active_local_base(); + let count = self + .frames + .last() + .map_or(self.locals.len(), |frame| frame.local_count); + self.locals[base..base.saturating_add(count)] + .iter() + .enumerate() + .filter_map(|(slot, value)| match value { + Value::Callable(_) => Some((slot, value.clone())), + _ => None, + }) + .collect::>() + }; + let local_base = self.locals.len(); + self.locals.resize( + local_base.saturating_add(prototype.frame_local_count), + Value::Null, + ); + for binding in self.program.root_callable_bindings() { + // Mirror the interpreter's `enter_script_frame`: every root + // binding must fit the callee frame and reference a known + // prototype; a malformed program errors instead of silently + // skipping the slot. + let binding_prototype = self + .program + .callable_prototypes() + .get(binding.prototype_id as usize) + .ok_or(VmError::InvalidCallablePrototype(binding.prototype_id))?; + let slot = binding.local_slot as usize; + if slot >= prototype.frame_local_count { + return Err(VmError::InvalidFrameState( + "root callable binding is outside the script frame", + )); + } + self.locals[local_base + slot] = Value::Callable(Rc::new(CallableValue { + prototype_id: binding.prototype_id, + kind: binding_prototype.kind, + env: None, + })); + } + for (slot, value) in inherited { + if slot < prototype.frame_local_count { + self.locals[local_base + slot] = value; + } + } + for (slot, argument) in prototype.parameter_slots.iter().zip(operands) { + let slot = *slot as usize; + if slot >= prototype.frame_local_count { + return Err(VmError::InvalidCallablePrototype(prototype_id)); + } + self.locals[local_base + slot] = argument; + } + self.frames.push(ExecutionFrame { + return_ip: self.ip, + operand_stack_base: stack_base, + local_base, + local_count: prototype.frame_local_count, + prototype_id, + }); + self.ip = function.entry_ip as usize; + Ok(()) + } + fn return_from_frame(&mut self) -> VmResult { let Some(frame) = self.frames.pop() else { return Ok(false); @@ -409,6 +527,11 @@ impl Vm { let arity = self.read_u8()?; self.call_value(arity)?; } + OpCode::CallScript => { + let prototype_id = self.read_u32()?; + let arity = self.read_u8()?; + self.call_script(prototype_id, arity)?; + } OpCode::Shl => { let rhs = self.pop_shift()?; diff --git a/pd-vm-nostd/src/vmbc.rs b/pd-vm-nostd/src/vmbc.rs index 77d8b9b2..d3ede42a 100644 --- a/pd-vm-nostd/src/vmbc.rs +++ b/pd-vm-nostd/src/vmbc.rs @@ -3,12 +3,12 @@ use alloc::vec::Vec; use super::{ CallableKind, CallablePrototype, CallableTarget, CaptureBindingMode, ExportedCallable, - FunctionRegion, HostImport, Program, RootCallableBinding, ScriptFunction, Value, ValueType, - WireError, + FunctionRegion, HostImport, OpCode, Program, RootCallableBinding, ScriptFunction, Value, + ValueType, WireError, }; const MAGIC: [u8; 4] = *b"VMBC"; -const VERSION_V11: u16 = 11; +const VERSION_V12: u16 = 12; const FLAGS: u16 = 0; const MAX_SCHEMA_DEPTH: usize = 64; const MAX_CONSTANT_DEPTH: usize = 64; @@ -57,7 +57,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V11 { + if version != VERSION_V12 { return Err(WireError::UnsupportedVersion(version)); } let flags = cursor.read_u16()?; @@ -96,6 +96,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { if !cursor.is_empty() { return Err(WireError::TrailingBytes); } + validate_call_script_operands(&code, &callable_prototypes)?; let program = Program::new(constants, code, imports); let program = match encoded_local_count { @@ -349,6 +350,64 @@ fn read_callable_metadata(cursor: &mut Cursor<'_>) -> Result Result<(), WireError> { + let mut ip = 0usize; + while ip < code.len() { + let opcode_byte = code[ip]; + let Ok(opcode) = OpCode::try_from(opcode_byte) else { + // Unknown opcodes surface as `InvalidOpcode` at run time; skip a + // single byte so the walk stays aligned for the opcodes that + // follow. + ip = ip.saturating_add(1); + continue; + }; + let operand_len = opcode.operand_len(); + let operands_start = ip.saturating_add(1); + let operands_end = operands_start + .checked_add(operand_len) + .ok_or(WireError::LengthTooLarge("code", code.len()))?; + if operands_end > code.len() { + return Err(WireError::TruncatedOperand { + opcode: opcode_byte, + expected_bytes: operand_len, + }); + } + if matches!(opcode, OpCode::CallScript) { + let prototype_id = u32::from_le_bytes( + code[operands_start..operands_start + 4] + .try_into() + .expect("operand width validated above"), + ); + let argc = code[operands_start + 4]; + let Some(prototype) = prototypes.get(prototype_id as usize) else { + return Err(WireError::InvalidCallScriptTarget { prototype_id }); + }; + // `CallScript` is a static script-function call: a host-import + // prototype must never be routed to the host path, so reject it + // deterministically here as well. + if !matches!(prototype.target, CallableTarget::ScriptFunction(_)) { + return Err(WireError::InvalidCallScriptTarget { prototype_id }); + } + if argc != prototype.arity { + return Err(WireError::InvalidCallScriptArity { + prototype_id, + expected: prototype.arity, + got: argc, + }); + } + } + ip = operands_end; + } + Ok(()) +} + fn skip_debug_info(cursor: &mut Cursor<'_>) -> Result<(), WireError> { match cursor.read_u8()? { 0 => Ok(()), diff --git a/pd-vm-nostd/tests/call_script_tests.rs b/pd-vm-nostd/tests/call_script_tests.rs new file mode 100644 index 00000000..3154a4df --- /dev/null +++ b/pd-vm-nostd/tests/call_script_tests.rs @@ -0,0 +1,365 @@ +//! Milestone 7: `CallScript` parity in the no_std + alloc runtime. +//! +//! Programs are produced by the std VMBC encoder (V12) or hand-built with +//! `CallScript` bytecode (0x1A, prototype_id:u32 LE, argc:u8) so the wire +//! contract and the typed validation/execution failures are pinned +//! independently of the compiler. + +use pd_vm_nostd::{ + Value as EmbeddedValue, Vm as EmbeddedVm, VmError, VmStatus as EmbeddedVmStatus, WireError, + decode_program, +}; +use vm::{ + CallableKind, CallablePrototype, CallableTarget, FunctionRegion, OpCode, Program, + ScriptFunction, compile_source, encode_program, +}; + +/// Build a main-crate program whose root code is `code` with one script +/// function (entry at `code.len()`) described by `prototype`. +fn raw_call_script_program(code: Vec, prototype: CallablePrototype) -> Program { + let function_entry = code.len() as u32; + let function_end = function_entry + 1; + let mut code = code; + code.push(OpCode::Ret as u8); + Program::new(Vec::new(), code) + .with_local_count(1) + .with_callable_metadata( + vec![ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![prototype], + vec![ + FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + vec![], + ) +} + +fn function_item_prototype( + target: CallableTarget, + arity: u8, + capture_slots: Vec, + self_slot: Option, +) -> CallablePrototype { + CallablePrototype { + kind: CallableKind::FunctionItem, + target, + arity, + frame_local_count: 1, + parameter_slots: (0..arity).map(u16::from).collect(), + capture_source_slots: Vec::new(), + capture_slots, + capture_modes: Vec::new(), + self_slot, + schema: None, + } +} + +#[test] +fn call_script_executes_direct_call() { + let compiled = compile_source("fn add2(value: int) -> int { value + 2 } add2(40);") + .expect("direct call source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("direct call program should encode as VMBC v12"); + let program = decode_program(&bytes).expect("no-std should decode VMBC v12"); + assert!( + program.code().windows(2).any(|pair| pair[0] == 0x1A), + "compiler output should contain CallScript" + ); + + let mut vm = EmbeddedVm::new(program); + assert_eq!( + vm.run().expect("direct call should halt"), + EmbeddedVmStatus::Halted + ); + assert_eq!(vm.stack(), &[EmbeddedValue::Int(42)]); +} + +#[test] +fn call_script_executes_nested_direct_calls() { + let compiled = compile_source( + "fn add2(value: int) -> int { value + 2 } fn add5(value: int) -> int { add2(value) + 3 } add5(0);", + ) + .expect("nested call source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("nested call program should encode"); + let program = decode_program(&bytes).expect("no-std should decode nested call program"); + + let mut vm = EmbeddedVm::new(program); + assert_eq!( + vm.run().expect("nested direct calls should halt"), + EmbeddedVmStatus::Halted + ); + assert_eq!(vm.stack(), &[EmbeddedValue::Int(5)]); +} + +#[test] +fn call_script_recursion() { + let compiled = compile_source( + "fn fact(n: int) -> int { if n <= 1 => { 1 } else => { n * fact(n - 1) } } fact(10);", + ) + .expect("recursion source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("recursion program should encode"); + let program = decode_program(&bytes).expect("no-std should decode recursion program"); + + let mut vm = EmbeddedVm::new(program); + assert_eq!( + vm.run().expect("recursion should halt"), + EmbeddedVmStatus::Halted + ); + assert_eq!(vm.stack(), &[EmbeddedValue::Int(3_628_800)]); +} + +#[test] +fn call_script_preserves_callee_local_isolation() { + let compiled = compile_source( + "fn set(value: int) -> int { let mut y = value; y = y + 1; y } let mut z = 10; z = set(z); z;", + ) + .expect("local isolation source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("local isolation program should encode"); + let program = decode_program(&bytes).expect("no-std should decode local isolation program"); + + let mut vm = EmbeddedVm::new(program); + assert_eq!( + vm.run().expect("local isolation should halt"), + EmbeddedVmStatus::Halted + ); + assert_eq!(vm.stack(), &[EmbeddedValue::Int(11)]); +} + +#[test] +fn call_script_depth_limit() { + let compiled = + compile_source("fn f() -> int { f() } f();").expect("recursion source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("recursion program should encode"); + let program = decode_program(&bytes).expect("no-std should decode recursion program"); + + let mut vm = EmbeddedVm::new(program); + vm.set_max_script_call_depth(4) + .expect("depth limit should be accepted"); + let err = vm + .run() + .expect_err("unbounded recursion should hit the depth limit"); + assert!( + matches!(err, VmError::CallStackOverflow), + "expected CallStackOverflow, got {err:?}" + ); +} + +#[test] +fn call_script_capture_prototype_fails_typed() { + // A script prototype that requires captures is wire-valid (runtime + // concern), but `CallScript` can never supply an environment: the no-std + // runtime must fail with the same typed error as the std interpreter. + let code = vec![OpCode::CallScript as u8, 0, 0, 0, 0, 0]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::ScriptFunction(0), 0, vec![0], None), + ); + let bytes = encode_program(&program).expect("capture program should encode"); + let decoded = decode_program(&bytes).expect("no-std should decode capture program"); + + let mut vm = EmbeddedVm::new(decoded); + let err = vm + .run() + .expect_err("capture-requiring prototype should fail through CallScript"); + assert!( + matches!(err, VmError::CallScriptRequiresEnvironment(0)), + "expected CallScriptRequiresEnvironment(0), got {err:?}" + ); +} + +#[test] +fn call_script_validation_rejects_out_of_range_prototype() { + let code = vec![OpCode::CallScript as u8, 7, 0, 0, 0, 0]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::ScriptFunction(0), 0, Vec::new(), None), + ); + let bytes = encode_program(&program).expect("program should encode"); + let err = decode_program(&bytes).expect_err("out-of-range prototype should be rejected"); + assert!( + matches!(err, WireError::InvalidCallScriptTarget { prototype_id: 7 }), + "expected InvalidCallScriptTarget(7), got {err:?}" + ); +} + +#[test] +fn call_script_validation_rejects_arity_mismatch() { + let code = vec![OpCode::CallScript as u8, 0, 0, 0, 0, 1]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::ScriptFunction(0), 0, Vec::new(), None), + ); + let bytes = encode_program(&program).expect("program should encode"); + let err = decode_program(&bytes).expect_err("arity mismatch should be rejected"); + assert!( + matches!( + err, + WireError::InvalidCallScriptArity { + prototype_id: 0, + expected: 0, + got: 1 + } + ), + "expected InvalidCallScriptArity, got {err:?}" + ); +} + +#[test] +fn call_script_validation_rejects_host_import_prototype() { + let code = vec![OpCode::CallScript as u8, 0, 0, 0, 0, 0]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::HostImport(0), 0, Vec::new(), None), + ); + let bytes = encode_program(&program).expect("program should encode"); + let err = decode_program(&bytes).expect_err("host-import target should be rejected"); + assert!( + matches!(err, WireError::InvalidCallScriptTarget { prototype_id: 0 }), + "expected InvalidCallScriptTarget(0), got {err:?}" + ); +} + +#[test] +fn call_script_validation_rejects_truncated_operands() { + // 0x1A followed by only two operand bytes. + let code = vec![OpCode::CallScript as u8, 1, 0]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::ScriptFunction(0), 1, Vec::new(), None), + ); + let bytes = encode_program(&program).expect("program should encode"); + let err = decode_program(&bytes).expect_err("truncated CallScript operands should be rejected"); + assert!( + matches!(err, WireError::TruncatedOperand { .. }), + "expected TruncatedOperand, got {err:?}" + ); +} + +#[test] +fn call_script_rejects_v11_wire_version() { + let compiled = compile_source("fn add2(value: int) -> int { value + 2 } add2(40);") + .expect("direct call source should compile"); + let mut bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("direct call program should encode"); + bytes[4..6].copy_from_slice(&11u16.to_le_bytes()); + let err = decode_program(&bytes).expect_err("VMBC v11 must be rejected"); + assert!( + matches!(err, WireError::UnsupportedVersion(11)), + "expected UnsupportedVersion(11), got {err:?}" + ); +} + +#[test] +fn call_script_fuel_interruption() { + let compiled = compile_source( + "fn bump(value: int) -> int { value + 1 } let mut i = 0; let mut total = 0; while i < 1000 { total = bump(total); i = i + 1; } total;", + ) + .expect("fuel source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("fuel program should encode"); + let program = decode_program(&bytes).expect("no-std should decode fuel program"); + + let mut vm = EmbeddedVm::new(program); + vm.set_fuel(64); + let err = vm + .run() + .expect_err("fuel should interrupt the direct call loop"); + assert!( + matches!(err, VmError::OutOfFuel { .. }), + "expected OutOfFuel, got {err:?}" + ); +} + +#[test] +fn call_script_stack_underflow_precedes_environment_rejection() { + // The interpreter checks operand underflow before prototype-driven + // rejection: a malformed `CallScript` with argc > 0 and an empty stack + // must report `StackUnderflow`, not `CallScriptRequiresEnvironment`, + // even when the target prototype requires captures. + let code = vec![OpCode::CallScript as u8, 0, 0, 0, 0, 1]; + let program = raw_call_script_program( + code, + function_item_prototype(CallableTarget::ScriptFunction(0), 1, vec![0], None), + ); + let bytes = encode_program(&program).expect("program should encode"); + let decoded = decode_program(&bytes).expect("no-std should decode program"); + + let mut vm = EmbeddedVm::new(decoded); + let err = vm + .run() + .expect_err("short operand stack must fail with StackUnderflow"); + assert!( + matches!(err, VmError::StackUnderflow), + "expected StackUnderflow, got {err:?}" + ); +} + +#[test] +fn call_script_binding_outside_frame_fails_typed() { + // A root callable binding whose slot lies outside the callee frame is + // invalid frame state: the no-std runtime must report the same typed + // error as the std interpreter instead of silently skipping the slot. + let mut code = vec![OpCode::CallScript as u8, 0, 0, 0, 0, 0]; + let function_entry = code.len() as u32; + code.push(OpCode::Ret as u8); + let function_end = code.len() as u32; + let program = Program::new(Vec::new(), code) + .with_local_count(2) + .with_callable_metadata( + vec![ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![function_item_prototype( + CallableTarget::ScriptFunction(0), + 0, + Vec::new(), + None, + )], + vec![ + FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + vec![vm::RootCallableBinding { + local_slot: 1, + prototype_id: 0, + }], + ); + let bytes = encode_program(&program).expect("program should encode"); + let decoded = decode_program(&bytes).expect("no-std should decode program"); + + let mut vm = EmbeddedVm::new(decoded); + let err = vm + .run() + .expect_err("out-of-frame root binding must fail on frame entry"); + assert!( + matches!( + err, + VmError::InvalidFrameState("root callable binding is outside the script frame") + ), + "expected InvalidFrameState for the out-of-frame binding, got {err:?}" + ); +} diff --git a/pd-vm-nostd/tests/embedded_vmbc.rs b/pd-vm-nostd/tests/embedded_vmbc.rs index 8bd2b5eb..1b29744f 100644 --- a/pd-vm-nostd/tests/embedded_vmbc.rs +++ b/pd-vm-nostd/tests/embedded_vmbc.rs @@ -29,9 +29,9 @@ fn encoded_scalar_program() -> Vec { } #[test] -fn embedded_decoder_reads_host_generated_v11() { +fn embedded_decoder_reads_host_generated_v12() { let bytes = encoded_scalar_program(); - let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v11"); + let program = decode_program(&bytes).expect("embedded decoder should accept VMBC v12"); assert_eq!( program.code(), @@ -182,7 +182,13 @@ fn embedded_runtime_executes_compiler_generated_capturing_callable() { } #[test] -fn removed_callable_creation_opcode_is_rejected() { - assert!(OpCode::try_from(0x1a).is_err()); - assert!(EmbeddedOpCode::try_from(0x1a).is_err()); +fn call_script_opcode_is_0x1a_in_both_crates() { + // The historical callable-creation opcode slot (0x1A) is now the static + // script-call opcode in both the std and embedded opcode tables. + assert_eq!(OpCode::try_from(0x1a), Ok(OpCode::CallScript)); + assert_eq!( + EmbeddedOpCode::try_from(0x1a), + Ok(EmbeddedOpCode::CallScript) + ); + assert!(EmbeddedOpCode::try_from(0x7f).is_err()); } diff --git a/pd-vm-wasm/src/analyzer.rs b/pd-vm-wasm/src/analyzer.rs index cb77f169..4c671efc 100644 --- a/pd-vm-wasm/src/analyzer.rs +++ b/pd-vm-wasm/src/analyzer.rs @@ -98,6 +98,15 @@ fn lint_compile_result( LintReport { diagnostics } } } + // Milestone 5+ load/merge errors carry the compilation-wide source + // map; unwrap it so the branch below renders from the owning source. + Err(SourcePathError::SourceWithMap { error, .. }) => lint_compile_result( + source, + flavor, + path, + options, + Err(SourcePathError::Source(error)), + ), Err(SourcePathError::Source(SourceError::Parse(err))) => { let mut diagnostics = lint_trailing_function_return_semicolon_diagnostics(source, flavor); @@ -115,7 +124,11 @@ fn lint_compile_result( source, flavor, path, options, )); let mut source_map = SourceMap::new(); - let source_id = source_map.add_source("", source.to_string()); + // Milestone 5+ compile errors name their owning source; register + // the root text under that name so the rendered snippet resolves + // against the right file. + let source_id = + source_map.add_source(err.source_name().unwrap_or(""), source.to_string()); let line = err.line().unwrap_or(0); let span = err.line().and_then(|value| { let span = source_map.line_span(source_id, value)?; diff --git a/pd-vm-wasm/src/lib.rs b/pd-vm-wasm/src/lib.rs index 9ccc0896..48b31cb6 100644 --- a/pd-vm-wasm/src/lib.rs +++ b/pd-vm-wasm/src/lib.rs @@ -1169,8 +1169,7 @@ mod runtime_tests { diagnostic.message ); assert!( - diagnostic.rendered.contains(":") - && diagnostic.rendered.contains("let value = if true => {"), + diagnostic.rendered.contains("let value = if true => {"), "expected rendered diagnostic snippet, got {:?}", diagnostic.rendered ); diff --git a/plans/2026-08-09_architecture-plan-index.md b/plans/2026-08-09_architecture-plan-index.md new file mode 100644 index 00000000..8cbc2085 --- /dev/null +++ b/plans/2026-08-09_architecture-plan-index.md @@ -0,0 +1,101 @@ +# RustScript Architecture Plan Index + +**Goal:** Classify the current VM/compiler/runtime findings into independent implementation plans with explicit dependency order. + +**Architecture:** Correctness fixes are separated from structural refactors. Each plan owns one architectural surface and defines its own boundary and target criteria. Agent-owned behavior remains in `rustscript-agent` plans. + +**Tech Stack:** RustScript compiler, VM, host runtime, interpreter/JIT/AOT/no-std, agent integration contracts. + +--- + +## Classification + +| Category | Problem surface | Independent plan | Depends on | +| --- | --- | --- | --- | +| Wire/ABI | Count-derived builtin indices change existing call IDs | `2026-08-09_static-builtin-id.md` | none | +| Compiler correctness | UTF-8 rewrite, parent-path normalization, nested source diagnostics, public entry-point parity | `2026-08-09_nested-module-correctness.md` | none | +| Compiler architecture | Text rewrite, synthetic preludes, flat global symbols, basename identity, source ownership | `2026-08-09_semantic-module-system.md` | nested correctness | +| Compiler/runtime correctness | Named script calls count separate callee frames as caller-live locals; aggregate overflow reports a sentinel; direct-only functions reserve hidden callable slots | `2026-08-11_frame-aware-local-allocation.md` | real script call frames; semantic module identities for cross-module use classification | +| Bytecode/wire capacity | Genuine same-frame local pressure above 256 cannot be encoded or consumed consistently | `2026-08-11_wide-local-bytecode.md` | frame-aware local allocation and callable slot reduction | +| VM ownership | Monolithic VM mixes engine/program/instance/run/host state | `2026-08-09_vm-runtime-decomposition.md` | static IDs | +| Host lifecycle | Generic resource/operation code unused; IO/HTTP/SQLite duplicate lifecycle/cancellation | `2026-08-09_unified-host-lifecycle.md` | VM decomposition | +| 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 | +| 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 | + +## Agent-owned plans + +| Category | Plan | +| --- | --- | +| Canonical product/framework roadmap | `rustscript-agent/plans/2026-07-30_rustscript-agent-gateway-api.md` | +| Run admission, structured input, result/events, timeout/cancellation, live delivery | `rustscript-agent/plans/2026-08-09_agent-run-lifecycle-events.md` | +| Transactional RSS storage, retention, restart, replay, idempotency | `rustscript-agent/plans/2026-08-09_agent-durable-state.md` | + +## Implementation route + +### Wave 0: Immediate correctness and identity + +Can run independently: + +1. Static builtin IDs. +2. Nested module correctness. + +Exit gate: static IDs no longer depend on catalog length; nested UTF-8/path/diagnostic regressions have executable coverage. + +### Wave 1: Foundational ownership and authorization + +Can run in parallel after relevant Wave 0 gates: + +1. VM runtime decomposition after static IDs. +2. Capability profile/host binding after static IDs. +3. Semantic module system after nested correctness. + +Exit gate: module semantics have explicit identities; VM has explicit ownership layers; every privileged call uses one authorization path. + +### Wave 2: Unified execution lifecycle + +1. Unified host resource/operation/cancellation lifecycle after VM ownership exists. +2. Invocation item stream and typed-error implementation on RunContext, integrating lifecycle cancellation as it becomes available. + +Exit gate: production host subsystems use one lifecycle, and every invocation yields bounded `Event` items followed by one `Complete` item or typed error, then ends. + +### Wave 3: Specialized consumers + +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. + +### Local-slot correction and capacity route + +This route is ordered independently of the host-lifecycle waves: + +1. Execute `2026-08-11_frame-aware-local-allocation.md` first. Land named-call frame-aware liveness and real-count diagnostics before direct-call/selective callable materialization. +2. Verify the storage-shaped dispatch fixture stays below the short-bytecode ceiling and the true same-frame 257-local control still fails for the declared capacity reason. +3. Execute `2026-08-11_wide-local-bytecode.md` only for genuine same-frame pressure. Preserve short opcode bytes and add VMBC/debug/backend support as one declared compatibility change. + +Exit gate: separate frames reuse relative slots, direct-only named functions do not require hidden callable locals, aggregate diagnostics report actual counts, and every runtime/tooling consumer agrees on short and wide local operands. + +## Scope boundary + +- No plan adds compatibility decoding for pre-static-ID VMBC. +- No plan adds agent/provider/platform policy to `rustscript`. +- No agent plan defines VM internal implementation. +- Correctness plans do not wait for structural refactors. +- Wide-local capacity does not compensate for cross-frame liveness over-allocation; the frame-aware plan is a hard dependency. +- 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. +- Core host macros contain no pd-edge scopes, context types, registry generation, or downstream module paths. + +## Target criteria + +- Every finding from the architecture/current-worktree review maps to one owning plan. +- Cross-plan dependencies are explicit and acyclic. +- Each plan has implementation route, scope boundary, and target criteria. +- Core and agent ownership do not overlap. +- Obsolete review-finding and mixed historical plans are removed after their live requirements are represented here. diff --git a/plans/2026-08-09_backend-semantic-convergence.md b/plans/2026-08-09_backend-semantic-convergence.md new file mode 100644 index 00000000..9b280532 --- /dev/null +++ b/plans/2026-08-09_backend-semantic-convergence.md @@ -0,0 +1,136 @@ +# Backend Semantic Convergence Plan + +**Goal:** Reduce semantic duplication across interpreter, Trace JIT, AOT, native bridge, and no-std execution by introducing one canonical instruction/operation contract and differential verification. + +**Architecture:** Bytecode semantics, builtin signatures, ownership rules, traps, and deoptimization outcomes are defined once. Each backend lowers or interprets the same contract. Generated coverage tables and differential fixtures detect missing or divergent implementations. + +**Tech Stack:** Rust 2024, interpreter, Trace JIT, Cranelift AOT, native bridge, `pd-vm-nostd`, property/differential tests. + +--- + +## Independence and dependency + +- Independent of agent framework and module loading. +- Static builtin IDs should land first. +- VM decomposition should define Engine/Program ownership before large backend file moves. +- This plan does not block immediate correctness plans. + +## Scope boundary + +### In scope + +- One canonical semantic description for opcodes and builtins. +- Generated backend coverage checks. +- Shared ownership/trap/helper contracts. +- Differential interpreter/JIT/AOT/no-std tests. +- Incremental removal of duplicated lowering logic. + +### Out of scope + +- New optimization targets or benchmark promises. +- New bytecode opcodes solely to simplify one backend. +- A complete JIT rewrite in one milestone. +- Agent, HTTP, SQLite, or gateway behavior. + +## Implementation route + +### Milestone 1: Build a backend coverage inventory + +**Files:** +- Create backend coverage tests/tools under `tests/` or `src/backend/` +- Read interpreter, JIT recorder/lowerer, AOT IR/lowerer, no-std dispatch + +Generate a matrix for every opcode/builtin: + +```text +semantic definition +interpreter +trace recorder +JIT lowering +AOT lowering +no-std +fallback/deopt rule +``` + +Fail CI when a newly added operation lacks an explicit backend disposition. + +### Milestone 2: Define canonical operation semantics + +**Files:** +- Create: `src/semantics/` or equivalent +- Modify opcode/builtin metadata generation + +Represent: + +- operand/result types and stack effect; +- ownership/borrow/clone/drop behavior; +- trap/error conditions; +- side-effect and suspension classification; +- interpreter helper and native helper ABI; +- deopt/fallback permission. + +Keep explicit Rust implementation hooks where declarative metadata is insufficient. + +### Milestone 3: Generate shared dispatch metadata + +1. Generate interpreter validation/stack-effect tables. +2. Generate JIT/AOT eligibility and helper IDs. +3. Generate no-std support/fallback declarations. +4. Key builtins by static ID. +5. Reject mismatched arity/type/ownership metadata at build time. + +### Milestone 4: Consolidate native helper contracts + +**Files:** +- Modify native bridge/helper modules +- Modify JIT/AOT lowerers + +1. Define one helper ABI for tagged/scalar/heap operands. +2. Centralize owned temporary and Arc/raw-pointer rules. +3. Centralize trap/status routing. +4. Remove backend-specific reinterpretation of the same helper payload. + +### Milestone 5: Add differential execution harness + +For generated and curated programs, compare: + +- return value and structured error; +- side-effect/event sequence; +- ownership/drop counters where observable; +- fuel/deadline behavior; +- interpreter, JIT, AOT, and no-std supported subsets. + +Include arrays/maps/bytes, calls/closures, branches/loops, host-call boundaries, traps, and deopt cases. + +### Milestone 6: Migrate one semantic family at a time + +Recommended order: + +1. scalar arithmetic/comparison; +2. stack/local/frame operations; +3. collection access/mutation; +4. calls/closures; +5. builtin/native helper calls; +6. suspension/deopt/terminal outcomes. + +Each family removes superseded duplicate tables after differential parity passes. + +### Milestone 7: Verification + +```bash +cargo fmt --all -- --check +cargo test --locked --workspace --all-features +cargo test --locked -p pd-vm-nostd +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +## Target criteria + +- Every opcode and builtin has one canonical semantic entry. +- Every backend declares implement/fallback/unsupported explicitly. +- New operations cannot compile without a complete backend disposition. +- Interpreter/JIT/AOT/no-std differential fixtures agree on the supported subset. +- Native ownership and trap ABI is shared by JIT and AOT. +- Backend-specific large files lose duplicated semantic policy over incremental milestones. +- Performance changes are measured separately from semantic convergence. diff --git a/plans/2026-08-09_capability-profile-host-binding.md b/plans/2026-08-09_capability-profile-host-binding.md new file mode 100644 index 00000000..578bd203 --- /dev/null +++ b/plans/2026-08-09_capability-profile-host-binding.md @@ -0,0 +1,137 @@ +# Capability Profile and Host Binding Contract Plan + +**Goal:** Authorize every privileged builtin and host import through one immutable, auditable capability profile keyed by static callable identity. + +**Architecture:** Compilation records required callable IDs; binding validates them against a `CapabilityProfile` with a stable fingerprint. Builtin fast paths, registry functions, overrides, cached plans, and cloned registries all use the same authorization decision before dispatch. + +**Tech Stack:** Rust 2024, generated builtin catalog, host function registry, proc macro, binding-plan tests. + +**Status:** Completed + +--- + +## Independence and dependency + +- Depends on static builtin IDs for durable callable identity. +- Can be implemented alongside VM decomposition; the VM-owned part is callable authorization only. +- HTTP/IO/SQLite configuration remains state owned by each host implementation and is not embedded in the profile. + +## Scope boundary + +### In scope + +- One authorization path for privileged builtins and registry host imports. +- Immutable capability profiles and stable fingerprints. +- A clean boundary between callable authorization and host-owned path/network/database/process limits. +- Binding-plan cache correctness under clone/mutation/generation changes. +- Explicit ordinary versus Edge proc-macro contracts. + +### Out of scope + +- Adding agent/provider-specific capabilities. +- Implementing filesystem/process/task functions. +- Backward-compatible acceptance of implicit broad profiles. +- HTTP DNS/SSRF implementation details. +- pd-edge release workflow changes. + +## Target model + +```text +CallableId = static builtin ID or explicit host import identity +CapabilityProfile + allowed callables + delegation limits + fingerprint + +HostFunctionState (type-erased by the VM, typed by each host module) + HTTP configuration and in-flight permits + IO path/process/size policy + SQLite root and resource limits + +BindingPlan + program requirements + resolved call targets + capability fingerprint + registry generation/identity +``` + +## Implementation route + +### Milestone 1: Add authorization regression tests + +Required failing cases: + +- empty profile rejects privileged builtin fast paths; +- empty profile rejects registry hosts and overrides; +- language-pure builtins remain available under the documented baseline profile; +- cached/uncached plans make identical decisions; +- sibling registry mutation invalidates or rejects stale plans; +- clone identity and structural independence follow one documented rule; +- parameter policy cannot be widened by script input. + +### Milestone 2: Define immutable profiles + +**Files:** +- Modify: `src/vm/host.rs` +- Create: `src/vm/capability.rs` +- Modify: `src/lib.rs` + +1. Separate pure language builtins from privileged host capabilities. +2. Key builtin permissions by explicit static ID. +3. Key external hosts by a stable import identity, not a mutable vector slot alone. +4. Keep HTTP/SQLite/IO policy types out of the VM capability layer. +5. Compute a deterministic fingerprint from callable authorization only. + +### Milestone 3: Put authorization before every dispatch path + +**Files:** +- Modify: `src/vm/host.rs` +- Modify builtin dispatch and fast-path entry points + +1. Resolve callable identity. +2. Authorize before builtin fast path, override, registry call, or native continuation. +3. Ensure a plan cannot grant a capability absent from the current profile. +4. Reject undeclared imports during preflight before side effects. + +### Milestone 4: Simplify plan cache identity + +1. Bind plans to program identity, registry identity/generation, and capability fingerprint. +2. Remove Arc-token combinations that encode overlapping identity concepts. +3. Define clone behavior explicitly and test source/sibling mutation. +4. Make stale-plan errors deterministic. + +### Milestone 5: Separate proc-macro contracts + +**Files:** +- Modify: `pd-host-function/src/lib.rs` +- Modify: `pd-host-function/src/edge.rs` +- Add independent consumer compile fixtures + +1. Require an explicit Edge marker/scope for Edge-only expansion. +2. Ordinary async functions must receive an ordinary supported expansion or a direct compile-time diagnostic. +3. Apply one scoped HTTP routing rule to VM-aware and args-only forms. +4. Hide Edge implementation paths from ordinary downstream consumers. +5. Test from a separate crate that has no pd-edge internals. + +### Milestone 6: Verification + +```bash +cargo fmt --all -- --check +cargo test --locked -p pd-host-function +cargo test --locked --test runtime_host_tests +cargo test --locked --test http_host_tests --features http-client +cargo test --locked --workspace --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +## Target criteria + +- No privileged builtin bypasses the capability decision. +- Empty means deny for every privileged path. +- Capability identity does not depend on catalog order or registry slot alone. +- Binding-plan cache keys contain one stable capability fingerprint. +- Clones and stale plans have tested deterministic semantics. +- Ordinary proc-macro consumers never receive Edge-private symbols. +- Parameter policies remain host-owned native upper bounds that script data cannot widen. +- `HostRuntime`, `CapabilityProfile`, and inherent `Vm` APIs do not name HTTP/IO/SQLite policy types. diff --git a/plans/2026-08-09_http-transport-security-executor.md b/plans/2026-08-09_http-transport-security-executor.md new file mode 100644 index 00000000..75ec902e --- /dev/null +++ b/plans/2026-08-09_http-transport-security-executor.md @@ -0,0 +1,182 @@ +# Async Host Transport Security Plan + +**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`. + +**Tech Stack:** Rust 2024, proc macros, `Future`, `HostAsyncBridge`, Reqwest/Tokio under the `async` feature, local HTTP fixtures. + +--- + +## Independence and dependency + +- Depends on the capability profile and unified host lifecycle. +- Adds a generic async host ABI before migrating IO or HTTP. +- Requires a coordinated `pd-edge` adapter migration because the current core proc-macro contains Edge-specific expansion. +- Independent of agent provider protocols and source-language `async`/`await` syntax. + +## Hard ownership rules + +1. The VM owns script suspension state and operation-ID allocation. +2. The embedding host owns future storage, waking, polling, cancellation, and executor/reactor integration. +3. A subsystem async host function owns only its future body and typed policy/context snapshot. +4. HTTP must not create a thread, Tokio runtime, oneshot completion scheduler, private pending map, private poller, or independent operation-ID namespace. +5. Core `pd-host-function` must not contain Edge scope enums, `SharedProxyVmContext`, `crate::abi_impl` paths, or pd-edge registry generation. +6. With the `async` feature disabled, IO keeps its blocking implementation and HTTP is absent from callable metadata and runtime registration. +7. With the `async` feature enabled, IO binds its async implementation and HTTP is available only through the async host ABI. +8. Async operation driving code lives in dedicated folders: core contracts/lifecycle under `src/vm/async_host/`, and the pd-edge driver under `pd-edge/src/async_host/`. It must not accumulate in `host.rs`, `abi_impl/mod.rs`, HTTP, or IO modules. + +## Scope boundary + +### In scope + +- Generic async `#[pd_host_function(name = "...")]` expansion using owned arguments. +- Host-driven future submission/poll/cancel ABI. +- Migration of pd-edge scope expansion to a pd-edge-owned proc-macro adapter. +- Feature-selected blocking/async IO implementations. +- Async-only generic HTTP host. +- Complete IPv4/IPv6 special-use classification. +- Async DNS, total/connect/first-byte/idle deadlines, destination pinning, redirect revalidation, and body limits. +- Cancellation/reset/drop tests across host-driver and transport phases. + +### Out of scope + +- A VM-owned Tokio runtime or process executor. +- HTTP-specific scheduling infrastructure. +- Provider JSON, retries, model selection, SSE semantic parsing, or agent loops. +- Ambient proxy support by default. +- Script-controlled policy relaxation. +- Source-language futures or `await` syntax. + +## Implementation route + +### Milestone 1: Freeze generic async host semantics with RED tests + +**Files:** +- Modify: `pd-host-function/src/lib.rs` +- Modify: `tests/host_binding_generation_tests.rs` +- Modify: VM host lifecycle tests + +Cover: + +- ordinary owned-argument async signatures are accepted; +- borrowed typed parameters and raw borrowed args are rejected for async hosts; +- generated wrappers submit exactly one `'static` future to the installed host driver; +- missing driver fails before a pending operation becomes visible; +- completion returns exactly one terminal result; +- cancellation/reset/drop cancel the driver operation exactly once; +- rejected submission retires the operation ID and leaves no waiting state; +- interpreter/JIT/AOT all suspend through the same `CallOutcome::Pending` boundary. + +### Milestone 2: Add the host-driven async ABI + +**Files:** +- Create: `src/vm/async_host/mod.rs` +- Create supporting files under `src/vm/async_host/` for lifecycle/bridge concerns when needed +- Modify: `src/vm/host.rs` only to remove superseded inline async lifecycle code +- Modify: `src/vm/host_runtime.rs` +- Modify: `src/vm/mod.rs` +- Modify: `build.rs` +- Modify: `pd-host-function/src/lib.rs` + +1. Define the boxed `'static` host future output contract. +2. Extend or replace `HostAsyncBridge` with explicit submit, poll, and cancel operations. +3. Allocate IDs through the shared operation registry before submission and retire them on every fallible path. +4. Generate an async host adapter that takes owned script arguments, constructs a future, and submits it through the VM boundary. +5. Classify async generated hosts as suspension-capable and exclude them from non-yielding native fast paths. +6. Keep cached registry binding and direct VM binding equivalent. + +### Milestone 3: Return Edge scope ownership to pd-edge + +**Core files:** +- Remove: `pd-host-function/src/edge.rs` +- Modify: `pd-host-function/src/lib.rs` + +**pd-edge files:** +- Create a pd-edge-owned proc-macro adapter crate or equivalent owned macro module. +- Create: `src/async_host/mod.rs` and focused driver/operation files beneath that folder. +- Migrate `scope = runtime/http/http_extension/transport`, bind parameters, registry generation, and `SharedProxyVmContext` preparation. +- Adapt `VmAsyncOpBridge` to the generic host submit/poll/cancel contract. +- Remove future storage, operation allocation, reactor entry, and bridge polling logic from `src/abi_impl/mod.rs`. + +The core proc-macro must retain only name-based generic sync/async host expansion. + +### Milestone 4: Provide blocking and async IO implementations + +**Files:** +- Modify: IO runtime modules, build generation, and IO tests + +1. Keep blocking IO available without the `async` feature. +2. Under `async`, bind the same script-facing IO API to async host functions with owned parameters. +3. Make build-time callable discovery honor the active feature so only one implementation enters metadata/registration. +4. Route async IO futures through the embedding host driver. +5. Preserve canonical path, process permission, byte, line, and handle policies in both variants. + +### Milestone 5: Migrate HTTP to an async host function + +**Files:** +- Modify: `Cargo.toml` +- Modify: `src/builtins/runtime/http.rs` +- Modify: `src/builtins/runtime/mod.rs` +- Modify: `tests/vm/http_host_tests.rs` + +1. Gate HTTP dependencies, callable metadata, configuration, and tests on `async`. +2. Convert `http::client::request` to a true async host function with an owned request and immutable policy/context snapshot. +3. Delete `schedule_request`, `HttpCompletion`, `HttpRequestResource`, oneshot completion, per-request threads/runtimes, and HTTP-specific pending polling. +4. Resolve DNS inside the submitted future and count it against the total deadline. +5. Validate and pin every resolved address before connection while preserving TLS hostname/SNI verification. +6. Repeat policy, credential stripping, pinning, and deadline checks for every redirect. +7. Enforce request/header/body, connect, first-byte, idle, total, and response-byte limits. +8. Disable ambient environment proxies unless the embedding supplies explicit policy. + +### Milestone 6: Cancellation and lifecycle convergence + +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. +4. Confirm no HTTP or IO async subsystem resource remains after completion/cancellation. +5. Remove runtime owner-poller routing that became obsolete after host-driver migration. + +### Milestone 7: Verification + +Core: + +```bash +cargo fmt --all -- --check +cargo test --locked -p pd-host-function +cargo test --locked --test host_binding_generation_tests --all-features +cargo test --locked --test io_builtin_edge_tests --all-features +cargo test --locked --test http_host_tests --features async +cargo test --locked --workspace --all-features +cargo test --locked --workspace --no-default-features --tests --no-run +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --all-features --no-deps +git diff --check +``` + +pd-edge: + +```bash +cargo fmt --all -- --check +cargo test --locked --workspace --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +All temporary and target directories must be under `/mnt/TEMP/rustscript/` and removed after final review. + +## Target criteria + +- A generic ordinary async host function compiles and suspends through the host driver. +- The host driver, not VM/HTTP/IO, stores and drives submitted futures. +- Async driving code is isolated in the dedicated core and pd-edge async-host folders. +- Core proc-macro code has no pd-edge scope or context knowledge. +- IO has blocking and async implementations selected by `async`. +- HTTP is unavailable without `async` and uses no private scheduler. +- DNS time counts against the total deadline. +- Every connected address is validated and pinned before connection. +- Special-use IPv4/IPv6 ranges are denied by default. +- Redirects repeat destination and credential validation. +- Cancellation during every transport phase reaches the driver and finishes within the documented bound. +- Reset/drop leave no future, operation, permit, stream, or response body live. +- HTTP host code contains no provider or agent policy. diff --git a/plans/2026-08-09_nested-module-correctness.md b/plans/2026-08-09_nested-module-correctness.md new file mode 100644 index 00000000..a19f2f46 --- /dev/null +++ b/plans/2026-08-09_nested-module-correctness.md @@ -0,0 +1,127 @@ +# Nested Module Correctness Implementation Plan + +**Goal:** Correct the current nested RSS module loader without expanding it into a new module architecture. + +**Architecture:** Keep the existing source-loader, import rewrite, and public `SourcePathError` shape, while repairing UTF-8 preservation, lexical path normalization, source ownership in diagnostics, and parity across public compile entry points. This is the short corrective plan; semantic module replacement is covered separately. + +**Tech Stack:** Rust 2024, RustScript compiler/source loader, `SourceMap`, CLI diagnostics, Cargo integration tests. + +--- + +## Independence and dependency + +- Independent of VM runtime, builtin IDs, agent gateway, HTTP, and persistence. +- Must complete before the semantic module-system plan begins. +- The agent repository only supplies a composition fixture; compiler behavior remains owned by `rustscript`. + +## Scope boundary + +### In scope + +- UTF-8-safe call-site rewrite. +- Correct preservation of unmatched relative `..` components. +- Canonical identity for equivalent disk module paths. +- Nested parse and strict-type diagnostics rendered from the real module source. +- Equivalent behavior for file, source-with-options, and source-at-path public entry points. +- Regression coverage for import/export/cycle behavior already changed in the worktree. + +### Out of scope + +- New public error enum variants. +- A compatibility wrapper for the removed `SourceAt` experiment. +- Semantic symbol resolution or a new IR. +- Agent storage migration or gateway changes. +- VM-visible opcodes or host capabilities. + +## Implementation route + +### Milestone 1: Add failing UTF-8 rewrite tests + +**Files:** +- Modify: `tests/compiler/module_import_tests.rs` +- Modify: unit tests in `src/compiler/source_loader/rewrite.rs` + +Add nested modules that trigger namespace and named-import rewriting while containing: + +- non-ASCII string literals; +- line and block comments; +- non-ASCII source outside rewritten spans where syntax permits it. + +Assert byte-for-byte preservation of untouched source and runtime preservation of values such as `"猫"`. + +### Milestone 2: Make scanners copy source slices + +**Files:** +- Modify: `src/compiler/source_loader/rewrite.rs` + +1. Stop appending UTF-8 bytes with `byte as char`. +2. Advance by valid UTF-8 scalar boundaries or copy untouched ranges as source slices. +3. Keep token recognition ASCII-specific where the grammar requires ASCII identifiers and separators. +4. Preserve comments, strings, escapes, and line counts exactly. + +### Milestone 3: Correct path normalization and identity + +**Files:** +- Modify: `src/compiler/source_loader/imports.rs` +- Modify: `src/compiler/source_loader/graph.rs` +- Test: `tests/compiler/module_import_tests.rs` + +1. Pop `ParentDir` only when the previous normalized component is `Normal`. +2. Never cancel an unmatched `ParentDir` with a later `ParentDir`. +3. Preserve root semantics for absolute paths. +4. Use canonical disk identity for files that exist; use a normalized explicit virtual identity for source overrides. +5. Key `seen`, `visiting`, exports, and overrides with the same module identity. +6. Test consecutive `super::`, absolute above-root input rejection/normalization policy, path aliases, cycle aliases, and duplicate import aliases. + +### Milestone 4: Carry nested source context to diagnostics + +**Files:** +- Modify: `src/compiler/source_loader.rs` +- Modify: `src/compiler/source_loader/graph.rs` +- Modify: `src/compiler/pipeline.rs` +- Modify: `src/cli.rs` +- Test: `tests/compiler/module_import_tests.rs` +- Add or modify CLI diagnostic integration tests + +1. Keep `SourcePathError` public enum shape unchanged. +2. Carry internal `{ path, source text, SourceId/span }` context through compilation. +3. Render nested parse and strict-type errors against the nested source, not the root source map. +4. Apply the same path/source enrichment to: + - `compile_source_file`; + - `compile_source_with_flavor_and_options`; + - `compile_source_at_path_with_flavor_and_options`. +5. Test path, line, code frame, underline, and source override content, not message text alone. + +### Milestone 5: Preserve import/export behavior + +Add regression cases for: + +- nested namespace aliases; +- nested named imports; +- public-only exports; +- no transitive re-export; +- same-directory `self::` and parent-directory `super::`; +- missing modules and normalized cycles; +- root and nested host namespace imports. + +### Milestone 6: Verification + +```bash +cargo fmt --all -- --check +cargo test --locked --test compiler_tests module_import +cargo test --locked --test compiler_tests +cargo test --locked --workspace --all-features +git diff --check +``` + +Run the CLI diagnostic fixture and assert that the rendered path and highlighted line both belong to the nested source. + +## Target criteria + +- Rewriting never changes untouched UTF-8 bytes. +- Consecutive unmatched parent components retain their lexical meaning. +- Equivalent disk paths resolve to one module identity. +- Nested diagnostics display the actual nested source line and underline. +- All public module-capable compile entry points identify the failing module. +- Existing public error enum shape remains unchanged. +- Import, export, and cycle tests pass without agent-specific compiler behavior. diff --git a/plans/2026-08-09_run-outcome-event-error-contract.md b/plans/2026-08-09_run-outcome-event-error-contract.md new file mode 100644 index 00000000..6e95cf32 --- /dev/null +++ b/plans/2026-08-09_run-outcome-event-error-contract.md @@ -0,0 +1,171 @@ +# Invocation Item Stream and Runtime Error Contract Plan + +**Goal:** Expose one small, Rust-like pull stream for each exported RSS invocation so structured arguments, emitted items, the final return value, cancellation, and errors have unambiguous semantics. + +**Architecture:** The host initializes a VM, resolves an exported callable, and starts an invocation with ordinary `Value` arguments. The invocation behaves like `Stream>`: `Event(Value)` items may arrive during execution, one `Complete(Value)` item carries the function return value, and the stream is fused after that terminal item or one error. `Vm::run()` remains the low-level execution pump; core does not add generator syntax, a second completion future, an embedding callback sink, or event persistence policy. + +**Tech Stack:** Rust 2024, existing VM callable APIs, `VmStatus`, generic runtime errors, existing async host bridge. + +--- + +## 1. Contract and non-goals + +### Public semantics + +```text +InvocationItem + Event(Value) + Complete(Value) + +Invocation stream item + Result + +poll_next + Pending(wait reason) + Ready(Some(Ok(Event(value)))) + Ready(Some(Ok(Complete(return_value)))) + Ready(Some(Err(runtime_error))) + Ready(None) // only after Complete or Err +``` + +The concrete API may use a small VM-specific poll enum instead of implementing `futures::Stream`; core must not create an executor or require a Tokio runtime. Its observable behavior must match a fused Rust stream. + +### Required rules + +- Input is passed as ordinary arguments to an exported callable such as `run(input)`. +- `stream::emit(value)` produces one `Event(value)` item and never changes the callable return value. +- A normal callable return produces exactly one `Complete(value)` item. +- Cancellation or failure produces exactly one typed error item and no `Complete` item. +- The next poll after `Complete` or `Err` returns end-of-stream. +- Polling drives execution. When the consumer stops polling, the VM does not continue producing items. +- At most one event item is buffered between polls; this provides natural backpressure without an event queue. +- Core validates only the configured per-item value bound. Run IDs, event names, sequence numbers, durable cursors, retention, replay, and platform delivery belong to the embedding. + +### Explicit non-goals + +- No source-language `yield`, generator object, `next(value)`, or resume-value semantics. +- No `RunOutcome`, `RunTermination`, `RunUsage`, terminal future, or event receipt type. +- No ambient-input builtin or JSON-specific input/output wrapper. +- No stack-top or event-last result inference. +- No agent/provider/platform event schema in core. + +## 2. Dependency boundary + +- Reuse exported callable identity and `Vm::resolve_exported_callable`. +- Reuse callable execution state from `start_callable`, `run`, and `take_callable_result`. +- Reuse `HostAsyncBridge`; an outstanding host operation maps to `Pending` and is resumed by the embedding-owned driver. +- Reuse unified cancellation tokens, but expose typed invocation cancellation through the public invocation API. +- Preserve the capability-profile and host-binding contracts unchanged. + +## 3. Implementation route + +### Milestone 1: Freeze stream behavior with failing tests + +**Files:** +- Create: `tests/invocation_stream_tests.rs` +- Modify: `tests/runtime_context_tests.rs` +- Modify: `tests/runtime_host_tests.rs` + +Add tests proving: + +1. `run(input)` receives the exact structured `Value` argument. +2. A script that emits `a`, emits `b`, and returns `c` produces `Event(a)`, `Event(b)`, `Complete(c)`, then end-of-stream. +3. A script with no events produces `Complete(value)`, then end-of-stream. +4. An event value never replaces or mutates the return value. +5. One poll exposes at most one event and execution does not advance while polling is paused. +6. A waiting host operation returns `Pending`, resumes through the existing async host driver, and preserves item order. +7. Cancellation produces one typed error item with its reason, then end-of-stream. +8. Fuel exhaustion, deadline expiry, and host failure each produce one typed error item, then end-of-stream. +9. Starting a second invocation on the same VM while one is active is rejected. +10. No public embedder needs to inspect the operand stack or compare error strings. + +### Milestone 2: Add the minimal invocation state machine + +**Files:** +- Create: `src/vm/invocation.rs` +- Modify: `src/vm/mod.rs` +- Modify: `src/vm/instance.rs` +- Modify: `src/lib.rs` + +1. Define `InvocationItem::{Event(Value), Complete(Value)}`. +2. Define one public invocation handle/state with `poll_next` and typed cancellation. +3. Start only from an initialized exported callable plus ordinary arguments. +4. Reuse `start_callable`, `run`, and `take_callable_result`; do not duplicate interpreter or async-host loops. +5. Enforce one active invocation per VM and fused termination. +6. Keep `Vm::run() -> VmResult` unchanged as the low-level pump. + +### Milestone 3: Turn runtime emit into one stream item + +**Files:** +- Modify: `src/builtins/runtime/context.rs` +- Modify: `src/builtins/runtime/event.rs` +- Modify: `src/builtins/runtime/context_host.rs` +- Modify: `src/vm/run_context.rs` + +1. Remove run-scoped ambient input storage and its script-visible builtins. +2. Replace the embedding-owned `EventSink` and cumulative event counters with one pending event slot owned by the active invocation. +3. Add the single script-visible `stream::emit(value)` builtin; it validates the per-item bound, places one pending event, and yields control to the invocation poller. +4. Resume the script after the caller consumes that item; `stream::emit` still evaluates to `()` inside RSS. +5. Remove core sequence assignment, event receipts, cumulative event-byte accounting, and sink rejection wrapping. +6. Do not add a JSON-specific emit builtin; adapters encode or decode JSON outside the VM contract. + +### Milestone 4: Capture the callable return explicitly + +**Files:** +- Modify: `src/vm/mod.rs` +- Modify: `src/vm/instance.rs` +- Test: `tests/invocation_stream_tests.rs` + +1. On normal callable completion, take the existing host callable result and emit `Complete(value)` once. +2. Never read the operand stack to infer the result. +3. Reject termination that lacks an explicit callable result as an internal frame-state error. +4. After `Complete`, release invocation state and return end-of-stream on every later poll. + +### Milestone 5: Preserve typed terminal errors and cancellation + +**Files:** +- Modify: `src/builtins/runtime/error.rs` +- Modify: `src/builtins/runtime/cancellation.rs` +- Modify: `src/vm/run_context.rs` +- Modify: `src/vm/invocation.rs` +- Modify: public VM error conversion paths + +1. Expose typed invocation cancellation without making `RunContext` public. +2. Preserve cancellation reason, deadline, fuel, resource, operation, and host error codes through the stream item. +3. Remove `HostError(String)` flattening from runtime capability paths consumed by the invocation API. +4. Emit one error item, cancel outstanding owned operations, release invocation state, and fuse the stream. +5. Do not add string parsing or dual legacy error contracts. + +### Milestone 6: Migrate embedders and remove superseded APIs + +**Files:** +- Modify: RustScript examples and embedding tests +- Coordinate: `rustscript-agent/src/lib.rs` + +1. Resolve an exported `run` callable and pass structured input as its argument. +2. Consume `Event` and `Complete` items in order. +3. Remove `events.last()`, `stack.last()`, ambient runtime input, and event sink setup. +4. Remove superseded runtime input/event exports after all in-repository consumers migrate. + +### Milestone 7: Verification + +```bash +cargo fmt --all -- --check +cargo test --locked --test invocation_stream_tests +cargo test --locked --test runtime_context_tests +cargo test --locked --test runtime_host_tests +cargo test --locked --workspace --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +## 4. Target criteria + +- The public invocation surface has one input path: exported callable arguments. +- The invocation yields zero or more `Event` items, then exactly one `Complete` item or one typed error, then ends. +- Events never replace the callable return value. +- Backpressure follows polling; no unbounded or embedding-callback event queue exists in core. +- `Vm::run` remains a low-level pump and no executor is introduced. +- Cancellation and runtime failures remain machine-readable. +- Core carries no event sequence, persistence, replay, or platform policy. +- No generator syntax or compatibility wrapper is introduced. diff --git a/plans/2026-08-09_semantic-module-system.md b/plans/2026-08-09_semantic-module-system.md new file mode 100644 index 00000000..96fb4040 --- /dev/null +++ b/plans/2026-08-09_semantic-module-system.md @@ -0,0 +1,154 @@ +# Semantic Module System Implementation Plan + +**Status (2026-08-09):** Milestones 1-7 complete, committed in b3ef8a7. +The semantic module graph is the sole file-module path: `rewrite.rs` and +`line_map.rs` are deleted, the synthetic imported-function prelude is gone, +and module sources are parsed verbatim with implicit-extern fallback and +resolved by `SymbolId` in the source loader (see +`src/compiler/source_loader.rs` module docs). Verification: `compiler_tests` +(215 tests incl. `semantic_module_m6_tests`), workspace all-features tests, +fmt, clippy (no new warnings), and `git diff --check` are green. + +**Goal:** Replace textual import rewriting and synthetic declarations with a semantic module graph and symbol resolution model. + +**Architecture:** Parse imports as syntax, assign every source a `ModuleId` and `SourceId`, resolve declarations to `SymbolId`, and link by resolved identity. Module namespaces, visibility, private helpers, and diagnostics become first-class compiler data instead of rewritten text and parallel metadata arrays. + +**Tech Stack:** Rust 2024, RustScript parser, frontend IR, compiler pipeline, linker, `SourceMap`. + +--- + +## Independence and dependency + +- Depends on completion of `2026-08-09_nested-module-correctness.md` so the current branch has a verified baseline. +- Independent of VM execution, host capabilities, HTTP, agent gateway, and backend optimization. +- May proceed in compiler-only milestones with bytecode output held behaviorally constant. + +## Scope boundary + +### In scope + +- `ModuleId`, `SourceId`, `SymbolId`, import edges, export tables, and visibility. +- Semantic namespace and named-import resolution. +- Private function identity across modules. +- Source-owned diagnostics after graph merge. +- Removal of synthetic function preludes and call-site text rewriting for file modules. + +### Out of scope + +- Package manager, remote modules, registry resolution, or dependency downloads. +- Dynamic module loading at VM runtime. +- New bytecode opcodes solely for module names. +- Host namespace redesign. +- Agent-specific storage or provider modules. + +## Implementation route + +### Milestone 1: Define compiler-owned identities + +**Files:** +- Create: `src/compiler/modules.rs` +- Modify: `src/compiler/source_loader.rs` +- Modify: `src/compiler/pipeline.rs` +- Test: compiler module tests + +Define: + +```text +ModuleId +SourceId +SymbolId +ModuleGraph +ModuleNode { source, imports, declarations, exports } +ResolvedImport +``` + +IDs are deterministic within one compilation and never derived only from a file stem. + +### Milestone 2: Parse import syntax into AST/IR + +**Files:** +- Modify RustScript parser/frontend import nodes +- Modify source-loader import discovery +- Test parser and module fixtures + +1. Stop using line-prefix stripping as the authoritative import parser. +2. Preserve import spans and clauses in the parsed unit. +3. Resolve `self::`, `super::`, namespace aliases, and named imports from structured nodes. +4. Keep host namespace imports on their existing dedicated resolution path. + +### Milestone 3: Build declarations and export tables + +**Files:** +- Modify frontend IR declaration metadata +- Modify `src/compiler/source_loader/graph.rs` +- Modify linker symbol collection + +1. Assign each declaration a symbol owned by its module. +2. Mark public exports explicitly. +3. Keep imported symbols separate from local declarations. +4. Prevent implicit transitive re-export. +5. Permit different modules to have private or public functions with the same source name. + +### Milestone 4: Resolve calls by symbol identity + +**Files:** +- Modify expression/call IR +- Modify `src/compiler/linker.rs` +- Modify lowering consumers + +1. Resolve local, named-import, and namespace calls to `SymbolId` before merge. +2. Replace string-based global function matching with symbol lookup. +3. Use deterministic internal mangling only at the final flat bytecode boundary if required. +4. Remove basename-only scope prefixes. + +### Milestone 5: Preserve source ownership through merge + +**Files:** +- Modify `src/compiler/pipeline.rs` +- Modify diagnostic/source-map structures +- Test rendered diagnostics + +1. Every span retains its source identity. +2. Merging units cannot reinterpret one module's offset in another source. +3. Parse, typing, duplicate symbol, visibility, and unresolved import errors render from the owning source. +4. Remove parallel `stmt_sources`, `function_sources`, and ad hoc prelude line remapping where replaced by source-owned IR. + +### Milestone 6: Remove textual compatibility machinery + +**Files:** +- Remove obsolete paths in `src/compiler/source_loader/rewrite.rs` +- Remove synthetic prelude generation and related line maps +- Update tests and compiler docs + +Do not retain a second module pipeline after semantic resolution reaches parity. + +### Milestone 7: Verification + +Required cases: + +- two directories containing modules with the same stem; +- two namespaces exporting the same function name; +- same-named private helpers in multiple modules; +- visibility errors and no transitive re-export; +- cycles through path aliases; +- in-memory overrides mixed with disk modules; +- deterministic output independent of import discovery order; +- source-correct diagnostics for every module. + +```bash +cargo fmt --all -- --check +cargo test --locked --test compiler_tests +cargo test --locked --workspace --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +## Target criteria + +- File-module calls are resolved by `SymbolId`, not rewritten source text. +- Module identity never depends only on a basename. +- Same-named declarations in independent modules coexist. +- Public/private and re-export rules are represented in compiler data. +- Synthetic imported-function preludes are removed from the module path. +- Every diagnostic span retains its owning source after linking. +- No agent-specific compiler rule is introduced. diff --git a/plans/2026-08-09_static-builtin-id.md b/plans/2026-08-09_static-builtin-id.md new file mode 100644 index 00000000..6296ecd7 --- /dev/null +++ b/plans/2026-08-09_static-builtin-id.md @@ -0,0 +1,107 @@ +# Static Builtin ID Implementation Plan + +**Goal:** Replace count-derived builtin call indices with explicit static IDs that remain fixed after assignment. + +**Architecture:** Every VM-visible builtin receives an explicit `u16` ID in one authoritative catalog. `build.rs`, the interpreter, compiler, wire encoder/decoder, and `pd-vm-nostd` consume generated tables from that catalog. This migration may break existing VMBC once; the implementation will bump the bytecode ABI and reject the previous format instead of carrying an old-ID decoder. + +**Tech Stack:** Rust 2024, `build.rs` code generation, VMBC wire format, `pd-vm`, `pd-vm-nostd`. + +--- + +## Independence and dependency + +- Independent of agent framework, module loading, HTTP behavior, and JIT refactoring. +- Must land before more builtins are added. +- Later capability plans may key permissions by the static builtin ID. + +## Scope boundary + +### In scope + +- Explicit IDs for ordinary, internal, and special-call builtins. +- One authoritative catalog and generated forward/reverse lookup. +- A one-time VMBC ABI version bump. +- Compile-time duplicate/range validation. +- Shared std/no-std ID generation. + +### Out of scope + +- Compatibility decoding for prior VMBC versions. +- Aliases from old IDs to new IDs. +- New builtin behavior or host capabilities. +- Changes to source-language names. + +## Implementation route + +### Milestone 1: Freeze the ID contract with failing tests + +**Files:** +- Modify: `tests/wire/wire_tests.rs` +- Modify: `src/bytecode.rs` +- Add fixture/catalog tests under `tests/wire/` + +1. Add assertions for explicit IDs of representative ordinary, internal, and special builtins. +2. Add a uniqueness test over the complete catalog. +3. Add range tests proving static IDs do not overlap opcodes or reserved sentinels. +4. Add a test that appending a synthetic catalog entry does not change existing IDs. + +**RED command:** + +```bash +cargo test --locked --test wire_tests builtin +``` + +### Milestone 2: Introduce the authoritative catalog + +**Files:** +- Modify: `build.rs` +- Modify: `src/builtins/mod.rs` or create `src/builtins/catalog.rs` +- Modify generated builtin metadata consumers + +1. Define each entry as `{ id, source_name, Rust variant, class, feature gate }`. +2. Remove `BUILTIN_CALL_BASE` arithmetic from ID assignment. +3. Generate `BuiltinFunction::call_index`, reverse lookup, dispatch tables, and catalog iteration from explicit IDs. +4. Fail the build on duplicate IDs, duplicate names, out-of-range IDs, or a missing explicit ID. +5. Reserve documented ID blocks for ordinary, internal, and future extension entries without deriving IDs from catalog length. + +### Milestone 3: Share IDs with no-std + +**Files:** +- Modify: `pd-vm-nostd/src/vm.rs` +- Modify: `pd-vm-nostd/build.rs` or generate a shared checked-in artifact +- Modify: no-std wire tests + +1. Remove the duplicated `BUILTIN_BASE` constant. +2. Generate or import the same explicit ID table without requiring std-only dependencies. +3. Verify std compiler output executes under `pd-vm-nostd` with identical builtin dispatch. + +### Milestone 4: Declare the format break + +**Files:** +- Modify: `src/bytecode.rs` +- Modify: VMBC format tests and documentation + +1. Increment `BYTECODE_ABI_VERSION` once. +2. Reject the previous version with a deterministic unsupported-version error. +3. Do not add migration, dual decoding, or legacy aliases. +4. Regenerate only current-version fixtures. + +### Milestone 5: Full verification + +```bash +cargo fmt --all -- --check +cargo test --locked --test wire_tests +cargo test --locked -p pd-vm-nostd +cargo test --locked --workspace --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +## Target criteria + +- Every VM-visible builtin has one explicit static ID. +- Adding or reordering catalog entries leaves all prior explicit IDs unchanged. +- Duplicate or missing IDs fail during generation. +- std and no-std use the same IDs without manually mirrored base arithmetic. +- VMBC declares the one-time incompatible format change and rejects the old version. +- No compatibility decoder or old-ID alias remains. diff --git a/plans/2026-08-09_structured-task-supervisor.md b/plans/2026-08-09_structured-task-supervisor.md new file mode 100644 index 00000000..40ae0db9 --- /dev/null +++ b/plans/2026-08-09_structured-task-supervisor.md @@ -0,0 +1,134 @@ +# Structured Task Supervisor Implementation Plan + +**Goal:** Add implementation-independent structured concurrency for multiple pending operations and isolated child program runs without exposing Rust futures, threads, or executor handles to scripts. + +**Architecture:** A run-scoped `TaskSupervisor` owns child operation/program tasks, concurrency permits, cancellation tree, result ordering, and cleanup. Tasks are descriptors validated against delegated capability profiles. Parent completion cannot leave active descendants. + +**Tech Stack:** Rust 2024, RunContext, HostRuntime operation registry, isolated VM instances, bounded executor. + +--- + +## Independence and dependency + +- Depends on VM decomposition and unified host lifecycle. +- Consumes static capability identity/profile delegation. +- Independent of agent tool/subagent policy; agent RSS may wrap it later. + +## Scope boundary + +### In scope + +- Multiple active operation/task records per run. +- Bounded `all`, `pool`, `race`, fail-fast, and isolated program fanout semantics. +- Parent/child cancellation and resource budgets. +- Ordered result collection and event association. +- Removal of the one-waiting-slot architectural limitation for structured tasks. + +### Out of scope + +- Source-language `async`, `await`, arbitrary futures, or shared-memory threads. +- Agent-specific tool, provider, or subagent descriptors. +- Mutable resource sharing between child VMs. +- Distributed execution or durable background jobs. + +## Target contracts + +```text +TaskSupervisor + spawn(descriptor, delegated_profile) + all(task_ids) + pool(descriptors, max_concurrency, fail_fast) + race(task_ids) + cancel(task_id/reason) + cancel_all(reason) + +TaskDescriptor + host operation + isolated program + input + +TaskResult + index + terminal status + return value or structured error + usage +``` + +## Implementation route + +### Milestone 1: Freeze structured semantics with tests + +Cover: + +- ordered all/pool results despite completion order; +- race returns first success and cancels remaining tasks; +- fail-fast cancellation; +- collect-all partial failures; +- parent cancellation reaches every descendant; +- child cancellation does not affect siblings by default; +- depth/fanout/active/time/fuel/operation limits; +- no child result/event after parent terminal state; +- isolated stacks/resources/capability profiles. + +### Milestone 2: Add TaskSupervisor to RunContext/HostRuntime + +**Files:** +- Create: `src/builtins/runtime/task.rs` +- Modify: RunContext and HostRuntime component files +- Modify: operation registry integration + +1. Store task state outside Instance's single wait marker. +2. Register every task/child operation in the shared operation registry. +3. Allocate permits before spawn. +4. Create child cancellation tokens under the run token. +5. Associate result/event/usage with task and parent run identity. + +### Milestone 3: Support isolated child programs + +1. Spawn a fresh Instance and RunContext from immutable Program/Engine references. +2. Delegate only a subset of the parent capability profile. +3. Prohibit mutable resource-handle transfer. +4. Bound child input/output/event bytes. +5. Finalize child outcome before collection. + +### Milestone 4: Add generic task host surface + +Expose implementation-independent operations such as: + +```text +task::all +task::pool +task::race +task::run_program +task::run_program_many +``` + +Descriptors contain only generic host-operation or program references. They cannot contain agent tool/provider names. + +### Milestone 5: Integrate scheduler wake/resume + +1. Permit multiple active task operations while Instance waits on one structured join/select result. +2. Wake the instance when the requested aggregate condition is met. +3. Keep remaining task state under supervisor ownership. +4. Cancel and clean all descendants before parent terminal completion. + +### Milestone 6: Verification + +```bash +cargo fmt --all -- --check +cargo test --locked --test runtime_task_tests +cargo test --locked --workspace --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +Include stress tests for permit exhaustion, cancellation races, deterministic ordering, nested depth, and cleanup counters. + +## Target criteria + +- One run can own multiple active tasks under explicit bounds. +- Structured joins/races have deterministic documented semantics. +- Parent terminal state implies zero active descendants. +- Child VMs share immutable program/engine data only. +- Capability delegation can only narrow access. +- Mutable resource handles never cross child boundaries. +- Agent/provider concepts do not appear in the core task descriptors. diff --git a/plans/2026-08-09_unified-host-lifecycle.md b/plans/2026-08-09_unified-host-lifecycle.md new file mode 100644 index 00000000..5a28066e --- /dev/null +++ b/plans/2026-08-09_unified-host-lifecycle.md @@ -0,0 +1,160 @@ +# Unified Host Resource, Operation, and Cancellation Plan + +**Goal:** Make every privileged runtime subsystem use one resource arena, operation registry, cancellation model, and cleanup lifecycle. + +**Architecture:** `HostRuntime` owns typed opaque resources, operation identity, and cancellation state. Blocking IO and SQLite resources use the shared arena/registry. Async host futures are submitted to and driven by the embedding `HostAsyncBridge`; the VM retains only script suspension and lifecycle identity. Cancellation carries a structured reason and propagates from run to bridge operation and resource cleanup. + +**Tech Stack:** Rust 2024, existing runtime hosts, `HostOpId`, HTTP client, SQLite, VM reset/drop tests. + +**Status:** Completed + +**Async correction:** The original HTTP/IO migration used subsystem pollers and, for HTTP, a per-request thread/runtime. Those transitional paths are superseded by `2026-08-09_http-transport-security-executor.md`. Completion of this plan does not authorize VM-, HTTP-, or IO-owned async executors. + +**Directory correction:** Generic async host contracts and VM lifecycle glue belong under `src/vm/async_host/`; each embedding's concrete driver belongs in its own dedicated async-host folder. `host.rs` and subsystem modules must remain binding/business-logic surfaces rather than async driver containers. + +--- + +## Independence and dependency + +- Depends on the HostRuntime/RunContext ownership contract from `2026-08-09_vm-runtime-decomposition.md`. +- Capability authorization can be implemented in parallel if it targets the same HostRuntime boundary. +- Agent lifecycle consumes this contract but is not implemented here. + +## Scope boundary + +### In scope + +- One opaque resource handle format and typed resource validation. +- One operation-ID/cancellation registry, with async future dispatch delegated to the embedding host bridge. +- Cancellation tree/reasons, deadlines, cleanup, and terminal state. +- Migration of blocking IO and SQLite state plus lifecycle identity for host-driven async operations. +- Removal of unused generic substrate and subsystem-specific duplicate registries. + +### Out of scope + +- Agent subagent semantics or provider retries. +- New filesystem/process/task host APIs. +- Source-language futures, `async`, or `await` syntax. +- Sharing mutable resources across VMs. + +## Target contracts + +```text +ResourceArena + insert(type, value, cleanup) + get(handle, expected_type) + close(handle, reason) + close_all(reason) + +OperationRegistry + start(owner, cancellation, cleanup) + complete(id, result) + cancel(id, reason) + cancel_all(reason) + +HostAsyncBridge + submit(id, future) + poll(id, waker) + cancel(id, reason) + +CancellationToken + parent + reason + deadline + child tokens +``` + +Handles must encode enough table/generation/type identity to reject stale, forged, cross-type, and cross-VM use. + +## Implementation route + +### Milestone 1: Freeze lifecycle semantics with tests + +Add tests for: + +- stale handle after close; +- handle reuse with generation change; +- wrong resource type; +- cross-VM handle rejection; +- operation completion/cancel race; +- reset/drop cleanup exactly once; +- parent cancellation propagation; +- timeout, user stop, resource close, and VM reset reasons. + +### Milestone 2: Replace the unused generic substrate + +**Files:** +- Modify: `src/builtins/runtime/resource.rs` +- Modify: `src/builtins/runtime/cancellation.rs` +- Modify: `src/vm/host_runtime.rs` + +1. Store opaque host resources, not only language `Value` objects. +2. Define a resource type identifier and cleanup contract. +3. Make operation owner/poll/cancel routing data-driven. +4. Remove APIs that remain unused after the contract is fixed. + +### Milestone 3: Migrate SQLite + +**Files:** +- Modify: `src/builtins/runtime/sqlite.rs` +- Modify: SQLite tests + +1. Replace SQLite-local handle counters and connection maps with ResourceArena handles. +2. Replace SQLite-local pending-op maps/signals with OperationRegistry. +3. Register `InterruptHandle` cancellation cleanup. +4. Ensure reset/drop waits only for the bounded documented grace period and no operation can re-enter a completed run. +5. Preserve path, SQL, row, byte, transaction, and authorizer limits. + +### Milestone 4: Establish HTTP lifecycle identity + +**Files:** +- Modify: `src/builtins/runtime/http.rs` +- Modify: HTTP tests + +1. Allocate HTTP-visible wait identities through the shared operation registry. +2. Connect run cancellation/reset/drop to the host async bridge. +3. Do not store or poll HTTP futures through HTTP-specific resources. +4. Remove HTTP-specific pending dispatch from `runtime/mod.rs` during the async host migration. + +### Milestone 5: Migrate IO and other existing resources + +**Files:** +- Modify IO runtime modules and VM host polling + +Keep blocking file/iterator/callback resources in the shared arena/registry. Async IO futures use the embedding host bridge under the `async` feature. Delete subsystem counters/maps and pollers after migration. + +### Milestone 6: Centralize wait/poll/cancel + +**Files:** +- Modify: `src/builtins/runtime/mod.rs` +- Modify: `src/vm/host.rs` +- Modify: `src/vm/mod.rs` or new component files + +1. Replace subsystem `if` chains with one bridge dispatch for async host operations. +2. Let Instance wait on an operation ID while HostRuntime owns lifecycle state and the embedding owns the future. +3. Route run cancellation, deadline, resource close, reset, and drop through one cancellation API. +4. Guarantee one terminal transition and one cleanup execution. + +### Milestone 7: Verification + +```bash +cargo fmt --all -- --check +cargo test --locked --test runtime_context_tests +cargo test --locked --test runtime_host_tests +cargo test --locked --test http_host_tests --features http-client +cargo test --locked --test sqlite_host_tests --features sqlite +cargo test --locked --workspace --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +## Target criteria + +- Production blocking IO/SQLite paths use the shared arena and registry; async IO/HTTP use shared lifecycle IDs plus the embedding host bridge. +- No subsystem owns an independent public operation-ID namespace. +- Cancellation reasons remain structured from run through cleanup. +- Close/reset/drop invoke cleanup once and reject stale handles afterward. +- A terminal run cannot receive a late operation result. +- Generic resource/operation code has production callers and no broad dead-code warnings. +- Per-subsystem async polling chains are removed. +- No HTTP/IO path creates a private thread, runtime, oneshot completion scheduler, or executor. diff --git a/plans/2026-08-09_vm-runtime-decomposition.md b/plans/2026-08-09_vm-runtime-decomposition.md new file mode 100644 index 00000000..0c080ffd --- /dev/null +++ b/plans/2026-08-09_vm-runtime-decomposition.md @@ -0,0 +1,148 @@ +# VM Runtime Decomposition Implementation Plan + +**Goal:** Split the current monolithic `Vm` state into explicit engine, program, instance, run-context, and host-runtime ownership layers. + +**Architecture:** Immutable compiled artifacts and backend caches live outside per-run execution state. An `Instance` owns interpreter state, a `RunContext` owns one invocation's pending stream item, budgets, and cancellation, and `HostRuntime` owns capabilities/resources/operations. Invocation input remains in ordinary callable arguments. The migration preserves observable execution behavior while removing subsystem-specific fields from the central VM object. + +**Tech Stack:** Rust 2024, `pd-vm` interpreter/JIT/AOT integration, existing compiler and runtime tests. + +--- + +## Independence and dependency + +- Static builtin IDs should land first so decomposition does not move an unstable wire catalog. +- Defines ownership required by the unified host-lifecycle and invocation-item-stream plans. +- Independent of agent providers, gateway routes, module semantics, and new host functions. + +## Scope boundary + +### In scope + +- Ownership split for program, backend cache, interpreter instance, run-scoped context, and host runtime. +- Explicit reset/drop semantics for each layer. +- Removal of subsystem fields from the top-level VM facade. +- Migration of embedding entry points to the new ownership model. + +### Out of scope + +- New language syntax or bytecode operations. +- New host capabilities. +- Compatibility adapters for every prior internal API. +- JIT/AOT optimization redesign. +- Agent-specific execution policy. + +## Target model + +```text +Engine + backend configuration + decoded/JIT/AOT caches + code-generation telemetry + +Program + immutable bytecode + constants and metadata + import requirements + +Instance + instruction pointer + stack, locals, frames, captures + yield/wait state + +RunContext + one pending invocation item + fuel/deadline/cancellation + +HostRuntime + capability profile + resources + operations + executor +``` + +The public facade may be renamed or retained, but ownership must follow this model. + +## Implementation route + +### Milestone 1: Add ownership tests + +**Files:** +- Add focused tests under `tests/vm/` +- Modify reset/reuse tests + +Prove: + +- one immutable program can create multiple isolated instances; +- pending invocation items and budgets never leak between runs; +- backend cache may be shared without sharing stacks/resources; +- reset closes run-scoped state and retains only documented reusable state. + +### Milestone 2: Extract immutable Program and Engine state + +**Files:** +- Modify: `src/vm/mod.rs` +- Create: `src/vm/engine.rs` +- Create or refine: `src/vm/program.rs` +- Move backend cache ownership from VM fields + +1. Remove raw program pointer/cache duplication from per-run state. +2. Give Engine explicit cache keys and invalidation rules. +3. Keep Program immutable after validation/binding metadata construction. +4. Test program sharing across interpreter-only, JIT, and AOT configurations. + +### Milestone 3: Extract Instance state + +**Files:** +- Create: `src/vm/instance.rs` +- Modify interpreter dispatch and frame helpers + +Move IP, stack, locals, frames, captures, callbacks, waiting/yield state, and instance-only counters. Define one lifecycle from new to halted/failed/cancelled. + +### Milestone 4: Introduce RunContext + +**Files:** +- Create: `src/vm/run_context.rs` +- Move the pending invocation item, fuel, epoch/deadline, and cancellation state + +1. Create a fresh RunContext per invocation. +2. Make cancellation and deadline mandatory run-owned data, with explicit unlimited settings where allowed. +3. Keep invocation input in callable arguments and remove source injection and embedding-global event ownership from execution paths. +4. Make run completion consume/finalize the context. + +### Milestone 5: Extract HostRuntime shell + +**Files:** +- Create: `src/vm/host_runtime.rs` +- Modify: `src/vm/host.rs` +- Modify: `src/builtins/runtime/mod.rs` + +Move capability profile, host bindings, resource tables, operation registry, and executor references behind HostRuntime. Subsystem migration proceeds in the separate host-lifecycle plan. + +### Milestone 6: Remove duplicate lifecycle paths + +1. Replace central constructor/reset/drop field lists with component lifecycle methods. +2. Remove fields that exist only as transitional mirrors. +3. Remove old internal APIs once all callers move; no long-lived compatibility layer. +4. Document thread-safety and clone semantics for Engine, Program, Instance, RunContext, and HostRuntime. + +### Milestone 7: Verification + +```bash +cargo fmt --all -- --check +cargo test --locked --workspace --all-features +cargo test --locked -p pd-vm-nostd +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +Add behavioral comparison fixtures that run the same program before and after each extraction milestone. + +## Target criteria + +- Immutable Program data and backend caches are not owned by per-run state. +- Stack/frame/wait state is isolated in Instance. +- Pending invocation item, budget, and cancellation are isolated in RunContext; input stays in callable arguments. +- Capabilities/resources/operations are isolated in HostRuntime. +- Reset and drop no longer enumerate every runtime subsystem in one central method. +- Multiple instances from one program cannot share mutable run or host resources. +- Existing interpreter/JIT/AOT/no-std behavior tests remain passing. diff --git a/plans/2026-08-11_frame-aware-local-allocation.md b/plans/2026-08-11_frame-aware-local-allocation.md new file mode 100644 index 00000000..92ed2b7c --- /dev/null +++ b/plans/2026-08-11_frame-aware-local-allocation.md @@ -0,0 +1,610 @@ +# Frame-Aware Local Allocation and Callable Slot Reduction Implementation Plan + +**Goal:** Correct named-script-call liveness for real call frames, report aggregate frame-local overflow with real counts, and stop reserving one hidden local for every directly called named function. + +**Architecture:** Treat each script invocation frame as a separate local-address space. Caller liveness includes argument evaluation and values used after the call, while callee body locals are analyzed inside the callee frame. Keep conservative ownership rules for dynamic callables and captures until their frame/environment behavior is proved separately. After correctness is established, lower eligible named calls through an additive direct-script-call opcode and materialize `Value::Callable` locals only where runtime identity or an environment is required. + +**Tech Stack:** Rust 2024, RustScript frontend/IR/lifetime passes, bytecode assembler, interpreter, VMBC, Trace JIT, AOT, debugger, wasm analyzer, and `pd-vm-nostd`. + +--- + +## Status and dependency + +- Status: proposed. +- Execute this plan before `2026-08-11_wide-local-bytecode.md`. +- The frame-aware correction and diagnostic milestones are correctness work and may land before direct-call optimization. +- The direct-call milestone depends on frame-aware allocation being verified independently. +- The agent storage program is a regression shape, not an owner of compiler policy. Core tests must use self-contained RustScript fixtures or generated sources. + +## Observed baseline + +The production-shaped storage source currently merges to: + +```text +frontend locals: 205 +named script function implementations: 77 +``` + +The existing compiler produces: + +```text +31 dispatch branches: 178 data slots + 77 callable slots = 255 frame slots +32 dispatch branches: 181 data slots + 77 callable slots = 258 frame slots +``` + +`Compiler::prepare_named_callables` rejects the second program because `Ldloc` and `Stloc` still use one-byte operands. It reports `LocalSlot::MAX` (`65535`) as a sentinel, hiding the actual total of 258. + +A diagnostic experiment showed: + +```text +remove only caller-live += callee-footprint: 19 data + 77 callable = 96 +also remove named-call cross-frame edges: 6 data + 77 callable = 83 +``` + +The experiment passed the complete `compiler_tests` and `vm_tests` integration targets, but it is evidence only. This plan requires dedicated ownership, capture, drop, recursion, module, JIT, AOT, and no-std coverage before changing production behavior. + +## Root cause to preserve in tests + +Real script frames were introduced in commit `0a8652c`. Runtime entry allocates a new `local_base`, resizes the locals array for the callee frame, copies parameters/captures into that frame, and restores the caller frame on return. + +The lifetime pipeline still carries two pre-frame assumptions for known named calls: + +1. `LivenessRewriter::add_expr_uses` unions the callee's transitive footprint into the caller live set. +2. `LocalSlotAllocator::collect_expr_constraints` adds caller/callee cross-live graph edges. + +Those assumptions make locals from separate frames interfere. Recursive call footprints can become `full_footprint`, magnifying the same problem. A separate cost comes from `prepare_named_callables`: every function implementation gets one hidden callable local, every call loads that local, and every runtime frame initializes all root callable bindings. + +## Semantic invariants + +The implementation must preserve all of the following: + +- Each execution frame has an independent relative local namespace and `local_base`. +- Arguments are fully evaluated in the caller before callee frame entry. +- Caller locals used after return remain live across the call. +- Callee locals are dropped according to the callee frame's own control flow. +- Copy, move, borrow, and borrow-mut capture cells retain existing alias and drop behavior. +- Capturing named functions and dynamic local callables retain environment identity. +- Recursion retains depth checks, self identity where required, and frame isolation. +- Exported callables remain resolvable through the public embedding API. +- Programs with more than 256 genuinely simultaneous locals in one frame continue to fail until the wide-local plan lands. +- Interpreter, JIT, AOT, no-std, VMBC, debugger, REPL, and wasm consumers remain behaviorally aligned. + +## Scope boundary + +### In scope + +- Frame-aware named-call liveness and interference constraints. +- Focused cleanup of named-call-only transitive footprint machinery after all callers are audited. +- Accurate aggregate frame-local diagnostics. +- Selective materialization of hidden named callable slots. +- An additive direct script call opcode for non-capturing statically resolved named calls. +- Required wire, interpreter, JIT, AOT, debugger, wasm, and no-std support for that opcode. +- Regression fixtures representing large dispatch across one file and semantic modules. +- Documentation of local allocation, frame ownership, and callable materialization. + +### Out of scope + +- `Ldloc` or `Stloc` operands wider than `u8`. +- More than 65,536 local slots. +- New language syntax. +- Changes to invocation item streams, host capabilities, agent lifecycle, or storage schemas. +- Rewriting dynamic `LocalCall` or closure-call conservatism without separate capture evidence. +- Inlining named functions as a substitute for frame-aware allocation. +- Agent-specific compiler exceptions, source-name checks, or compatibility wrappers. +- A generic register allocator or SSA rewrite. + +## Target architecture + +### Local pressure + +For each named script function and the root body: + +```text +same-frame live ranges -> one interference graph domain +caller arguments -> caller domain +callee body locals -> callee domain +capture environment -> explicit capture cells and capture metadata +``` + +The compiler may still assign one shared relative slot number to locals from different functions because runtime frame bases separate them. + +### Named call lowering + +Use two paths: + +```text +Direct non-capturing named call + arguments + CallScript(prototype_id, argc) + +Runtime-valued call + load/materialize Value::Callable + arguments + CallValue(argc) +``` + +A function requires callable materialization when any of these holds: + +- it is exported under the current `ExportedCallable { local_slot }` contract; +- it is referenced as a value; +- it captures an environment; +- a dynamic call site can target it; +- its runtime self identity is required by a capturing/dynamic recursion path. + +Plain direct calls, including non-capturing direct recursion, use `CallScript` and do not require a hidden local. + +## Milestone 1: Lock the failure shape and true-limit control + +**Objective:** Add RED tests that distinguish cross-frame over-allocation from genuine same-frame local pressure. + +**Files:** + +- Modify: `tests/compiler/compiler_common_tests.rs` +- Modify: `tests/compiler/compiler_rustscript_tests.rs` +- Modify: `tests/compiler/module_import_tests.rs` +- Create: `tests/fixtures/modules/frame_local_dispatch/main.rss` +- Create: module files under `tests/fixtures/modules/frame_local_dispatch/` + +**Steps:** + +1. Add a generated single-file program with roughly 77 named functions and a 32-branch dispatcher. Each callee owns two parameters and one local. The dispatcher must return a deterministic scalar so the test executes after compilation. +2. Add a semantic-module fixture with the same call graph split across multiple modules. This proves the result is independent of linker local-base assignment and import discovery order. +3. Assert both programs compile and execute under the interpreter. On the current tree, record RED as aggregate `LocalSlotOverflow(LocalSlot::MAX)`. +4. Assert `Program.local_count` stays bounded by per-frame pressure plus currently required callable slots. Before direct-call optimization, use an upper bound such as 100 rather than an exact coloring number. +5. Keep and strengthen the existing generated test with 257 values simultaneously live in one function. Assert it fails with a real frame-limit error after Milestone 3. +6. Add a 256-live boundary case that compiles and reads the highest short slot. + +**Focused RED command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests frame_local_dispatch +``` + +Expected before implementation: the 32-branch cases fail at compile time; the true 257-live control continues to fail for the intended reason. + +**Commit after GREEN:** + +```bash +git add tests/compiler/compiler_common_tests.rs \ + tests/compiler/compiler_rustscript_tests.rs \ + tests/compiler/module_import_tests.rs \ + tests/fixtures/modules/frame_local_dispatch/ +git commit -m "test(compiler): cover frame-local pressure across named calls" +``` + +Do not commit a branch on which the new success cases remain failing. + +## Milestone 2: Make named-call liveness frame-aware + +**Objective:** Stop treating a statically resolved callee body as live inside its caller frame. + +**Files:** + +- Modify: `src/compiler/lifetime/liveness.rs` +- Modify if comments/contracts require it: `src/compiler/lifetime/availability.rs` +- Test: `tests/compiler/compiler_common_tests.rs` +- Test: `tests/compiler/compiler_rustscript_tests.rs` +- Test: `tests/vm/drop_contract_tests.rs` through the `vm_tests` target + +**Steps:** + +1. In `LivenessRewriter::add_expr_uses`, classify `Expr::Call(index, ..., args)` using `function_impls.contains_key(index)`. +2. For a known named script call, add only caller-side argument uses. Do not union `function_footprint(index)` into the caller live set. +3. Continue analyzing each `FunctionImpl` body independently through `rewrite_function_impl` and `function_body_live_out`. +4. Preserve persistent capture sources and captured slots through `persistent_capture_slots`, function declaration rewriting, and closure environment metadata. +5. Leave `Expr::LocalCall` and unknown dynamic targets on their existing conservative path in this milestone. +6. Add runtime tests where: + - a caller local remains usable after a callee returns; + - caller and callee locals are assigned the same relative slot but retain different values; + - copy/move/borrow/borrow-mut captures observe existing behavior; + - direct and mutual recursion retain independent frame values; + - cancellation/yield in a callee resumes with caller locals intact; + - drop-contract counts do not double-drop or omit caller/callee heap values. +7. Run the large dispatch tests and inspect `Program.local_count`; expected data pressure should fall from 181 to approximately 19 even before removing the allocator cross-edge. +8. Remove `LivenessRewriter` footprint fields or methods only when repository search proves they have no remaining closure/dynamic-call use. Do not delete shared capture analysis merely because named calls no longer need it. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests frame_local +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests named_function +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test vm_tests drop_contract +``` + +Each command must select at least one test. + +**Commit:** + +```bash +git add src/compiler/lifetime/liveness.rs \ + src/compiler/lifetime/availability.rs \ + tests/compiler/compiler_common_tests.rs \ + tests/compiler/compiler_rustscript_tests.rs \ + tests/vm/drop_contract_tests.rs +git commit -m "fix(compiler): make named-call liveness frame-aware" +``` + +## Milestone 3: Remove stale named-call interference edges + +**Objective:** Make graph coloring match the runtime frame boundary without weakening dynamic callable safety. + +**Files:** + +- Modify: `src/compiler/lifetime/liveness.rs` +- Test: compiler and module tests from Milestone 1 + +**Steps:** + +1. In `LocalSlotAllocator::collect_expr_constraints`, stop adding caller-live versus callee-footprint edges for statically resolved named calls. +2. Continue collecting constraints for argument expressions in the caller. +3. Continue building cliques and def/live edges within each function body. +4. Preserve explicit capture-copy interference and persistent capture slots. +5. Preserve conservative `LocalCall` and closure-call handling until a separate test proves a narrower rule. +6. Add a test that two functions with disjoint execution frames reuse the same relative slots even when one calls the other recursively. +7. Add a negative control where two values truly overlap within one function and must receive different slots. +8. Assert the large storage-shaped fixture falls to a small per-frame data count, expected near 6. Avoid making the exact greedy-color result a public contract; assert a conservative upper bound such as 20. +9. Search for remaining named-call transitive-footprint use. Retain any helper still required by dynamic closures or capture lifetime analysis. + +**Focused command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests frame_local_slot_reuse +``` + +**Commit:** + +```bash +git add src/compiler/lifetime/liveness.rs \ + tests/compiler/compiler_common_tests.rs \ + tests/compiler/compiler_rustscript_tests.rs \ + tests/compiler/module_import_tests.rs +git commit -m "fix(compiler): isolate named-call interference by frame" +``` + +## Milestone 4: Report real aggregate frame-local pressure + +**Objective:** Replace the `65535` sentinel diagnostic with actionable counts while preserving individual operand overflow errors. + +**Files:** + +- Modify: `src/compiler/mod.rs` +- Modify: `src/compiler/codegen.rs` +- Modify: `src/compiler/diagnostics.rs` +- Modify: `tests/common/mod.rs` +- Modify: `tests/compiler/diagnostics_tests.rs` +- Modify: `pd-vm-wasm/src/analyzer.rs` if it matches compiler errors exhaustively + +**Design:** + +Add a dedicated error shape, for example: + +```rust +CompileError::FrameLocalLimitExceeded { + data_slots: usize, + callable_slots: usize, + total_slots: usize, + max_slots: usize, +} +``` + +Keep `CompileError::LocalSlotOverflow(slot)` for a concrete local index that cannot be emitted by the current ISA. + +**Steps:** + +1. In `prepare_named_callables`, compute `data_slots`, materialized callable slots, total, and maximum before mutating callable metadata. +2. Return `FrameLocalLimitExceeded` with actual values when the aggregate exceeds 256. +3. Remove uses of `LocalSlot::MAX` as an aggregate sentinel. +4. Render a diagnostic such as: + +```text +frame requires 258 local slots (181 data + 77 callable); short bytecode supports 256 +``` + +5. Preserve source diagnostics where an owning function/source span is available; otherwise use a program-level diagnostic without pretending slot 65535 exists. +6. Update wasm/common error mappings and snapshot tests. +7. Add direct tests for arithmetic overflow separately from the ordinary 256 ceiling. +8. Confirm no error text parser is introduced in core or downstream tests. + +**Focused command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests frame_local_limit_diagnostic +``` + +**Commit:** + +```bash +git add src/compiler/mod.rs src/compiler/codegen.rs src/compiler/diagnostics.rs \ + tests/common/mod.rs tests/compiler/diagnostics_tests.rs \ + pd-vm-wasm/src/analyzer.rs +git commit -m "fix(compiler): report actual frame-local pressure" +``` + +## Milestone 5: Classify named functions that require runtime materialization + +**Objective:** Separate statically called script functions from named functions that need a `Value::Callable` identity. + +**Files:** + +- Modify: `src/compiler/ir.rs` +- Modify: `src/compiler/parser/` consumers only if use metadata is unavailable after parsing +- Modify: `src/compiler/linker.rs` +- Modify: `src/compiler/pipeline.rs` +- Modify: `src/compiler/codegen.rs` +- Test: compiler, module, exported callable, capture, and recursion tests + +**Steps:** + +1. Add a compiler-internal use classification keyed by function index or `SymbolId`. Suggested facts: + +```text +called_directly +referenced_as_value +exported +captures_environment +dynamic_target_required +runtime_self_required +``` + +2. Collect value references from `Expr::FunctionRef`, exported declarations, closure/capture metadata, and any dynamic callable assignment. +3. Carry the classification through semantic module merge using resolved function identity, never source names. +4. Define `requires_callable_slot` from the semantic facts. Do not infer it from call count or source spelling. +5. Keep one prototype for every script function. Allocate a hidden local only for `requires_callable_slot` functions. +6. Keep exported functions materialized under the current `ExportedCallable { local_slot }` API in this plan. On-demand exported prototype creation is a later API proposal. +7. Ensure capturing named functions retain declaration-time environment construction and cannot use an environment-free direct call path. +8. Add tests for: + - direct-only helper: no hidden slot; + - exported direct helper: hidden slot retained; + - function stored in a local/map/array: hidden slot retained; + - capturing named function: hidden slot and environment retained; + - non-capturing direct recursion: no hidden slot required after `CallScript` exists; + - capturing recursion: runtime self slot retained; + - same names in different modules: classification follows `SymbolId`. + +This milestone may introduce metadata and tests before changing call lowering, but every commit must remain executable. If the compiler cannot omit a slot until `CallScript` exists, keep allocation behavior unchanged and commit only the classification plus passing tests of the classification helper. + +**Commit:** + +```bash +git add src/compiler/ir.rs src/compiler/parser/ src/compiler/linker.rs \ + src/compiler/pipeline.rs src/compiler/codegen.rs \ + tests/compiler/ tests/wire/ +git commit -m "refactor(compiler): classify named callable materialization" +``` + +Stage explicit files rather than directory globs during execution. + +## Milestone 6: Add direct script-call bytecode and interpreter support + +**Objective:** Call eligible environment-free named functions by prototype ID without loading a hidden callable local. + +**Files:** + +- Modify: `src/bytecode.rs` +- Modify: `src/assembler.rs` +- Modify: `src/compiler/codegen.rs` +- Modify: `src/vm/mod.rs` +- Modify: `src/vm/instance.rs` only if shared frame-entry logic belongs there +- Modify: `src/vmbc.rs` +- Modify: `src/debug_info.rs` +- Modify: debug-related bytecode scanners in `src/vmbc.rs` and `src/cli.rs` +- Modify: `src/cli.rs` +- Modify: `pd-vm-wasm/src/analyzer.rs` +- Test: compiler, VM, wire, debugger, REPL, wasm tests + +**ISA contract:** + +Reserve the next opcode after `CallValue`: + +```text +CallScript = 0x1A +operands = prototype_id:u32 little-endian, argc:u8 +length = 5 bytes +``` + +Do not repurpose `Call`, which remains host/builtin-only. Do not change `CallValue`. + +**Steps:** + +1. Add assembler emission and decoding for `CallScript`. +2. Add a shared VM helper that enters a script frame from `(prototype_id, optional environment, operands, continuation)`. +3. Route `CallValue` and `CallScript` through that helper. `CallScript` supplies no callable environment and must reject prototypes that require captures. +4. Preserve arity validation, depth limits, interruption ticks, return continuation, stack cleanup, and drop-contract behavior. +5. In `compile_function_call`, emit argument expressions followed by `CallScript` for an eligible function. Keep `Ldloc + CallValue` for materialized or capturing functions. +6. Omit hidden slots and root bindings for direct-only functions. Recompute `frame_local_count` from data slots plus materialized callable slots. +7. Retain `ExportedCallable.local_slot` and public `resolve_exported_callable` behavior. +8. Update opcode walkers, jump/region validation, debugger stepping, disassembly, and wasm analysis for the five-byte operand. +9. Bump VMBC from V11 to V12 and regenerate wire fixtures. The V12 decoder must reject malformed/truncated `CallScript` operands deterministically. Compatibility policy must follow the current release plan; do not silently decode V11 bytes under changed semantics. +10. Add tests proving: + - direct-only functions emit `CallScript` and no `Ldloc` target slot; + - environment-bearing functions still emit `CallValue`; + - direct recursion and mutual recursion work; + - exported function resolution remains unchanged; + - malformed prototype IDs and arity produce typed VM errors; + - short programs with no script calls retain unchanged instruction bytes apart from the declared VMBC version policy. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test compiler_tests direct_script_call +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test vm_tests call_script +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test wire_tests call_script +``` + +**Commit:** + +```bash +git add src/bytecode.rs src/assembler.rs src/compiler/codegen.rs \ + src/vm/mod.rs src/vm/instance.rs src/vmbc.rs src/debug_info.rs \ + src/cli.rs pd-vm-wasm/src/analyzer.rs tests/ +git commit -m "feat(vm): call static script functions by prototype" +``` + +During execution replace directory entries with the exact changed paths. + +## Milestone 7: Add no-std, Trace JIT, native, and AOT parity + +**Objective:** Ensure `CallScript` is a supported semantic operation across every execution backend. + +**Files:** + +- Modify: `pd-vm-nostd/src/program.rs` +- Modify: `pd-vm-nostd/src/vm.rs` +- Modify: `pd-vm-nostd/src/vmbc.rs` +- Modify: `pd-vm-nostd/src/error.rs` if new validation errors are needed +- Modify: `src/vm/jit/trace.rs` +- Modify: `src/vm/jit/recorder.rs` +- Modify: `src/vm/jit/inline.rs` +- Modify: `src/vm/jit/native/` lowering and runtime files +- Modify: `src/vm/native/bridge.rs` +- Modify: `src/vm/aot/cfg.rs` +- Modify: `src/vm/aot/ir.rs` +- Modify: `src/vm/aot/ssa.rs` +- Modify: `src/vm/aot/compile.rs` +- Modify: `src/vm/aot/runtime.rs` +- Modify: `src/vm/aot/artifact.rs` +- Test: no-std, JIT, native bridge, AOT, artifact, and backend parity tests + +**Steps:** + +1. Mirror the opcode and operand layout in no-std. Share semantic expectations through fixtures, not source-code dependency. +2. Teach every bytecode scanner to skip five operand bytes and preserve call boundaries. +3. Record `CallScript` with prototype identity and call-site IP. Reuse existing callable-frame JIT machinery instead of creating a second frame model. +4. Update inline candidate analysis to resolve the direct prototype without reading a source callable local. +5. Lower native/AOT direct calls through existing environment-free function-item paths. +6. Preserve deopt/exit restoration, frame keys, stack bases, return IPs, interruption checks, and typed call errors. +7. Increment `NATIVE_CALLABLE_ABI_VERSION`, AOT artifact version/ABI, and program/native cache revisions exactly once for the new opcode semantics. +8. Add parity tests for interpreter, trace/native JIT, AOT, and no-std using direct calls, recursion, nested calls, cancellation checks, and failure exits. Prefix the AOT-focused test names with `aot_call_script` so the verification command selects them explicitly. +9. Confirm JIT/AOT never reinterpret `CallScript` as host `Call` or dynamic `CallValue`. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked --test jit_tests call_script --features cranelift-jit +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked aot_call_script --features cranelift-jit +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target \ + cargo test --locked -p pd-vm-nostd call_script +``` + +**Commit:** + +```bash +git add pd-vm-nostd/src/ src/vm/jit/ src/vm/native/ src/vm/aot/ \ + tests/jit/ tests/wire/ pd-vm-nostd/tests/ +git commit -m "feat(vm): support direct script calls across backends" +``` + +Stage exact files during execution. + +## Milestone 8: Documentation and cleanup + +**Objective:** Remove obsolete assumptions and document the final pressure model. + +**Files:** + +- Modify: compiler lifetime module documentation +- Modify: callable/runtime documentation under `docs/` +- Modify: VMBC/opcode documentation +- Modify: `README.md` only if it states the old all-function hidden-slot model + +**Steps:** + +1. Document same-frame interference and cross-frame reuse. +2. Document which named functions receive hidden callable slots. +3. Document `Call`, `CallValue`, and `CallScript` ownership separately. +4. Remove dead named-call transitive-footprint caches and comments only after repository search proves no remaining use. +5. Remove temporary diagnostics and instrumentation. +6. Verify no agent/storage operation names appear in compiler logic. + +**Commit:** + +```bash +git add src/compiler/lifetime/ docs/ README.md +git commit -m "docs(compiler): define frame-local allocation boundaries" +``` + +Stage exact changed files only. + +## Verification matrix + +Use one isolated target directory for every Cargo command: + +```bash +export CARGO_TARGET_DIR=/mnt/TEMP/rustscript/frame-local-target +``` + +### Focused correctness + +```bash +cargo fmt --all -- --check +cargo test --locked --test compiler_tests frame_local +cargo test --locked --test compiler_tests named_function +cargo test --locked --test compiler_tests closure +cargo test --locked --test vm_tests call_script +cargo test --locked --test vm_tests drop_contract +cargo test --locked --test compiler_tests module_import +cargo test --locked --test wire_tests call_script +``` + +If `module_import_tests` is a module inside `compiler_tests` rather than a standalone Cargo target, run the corresponding `compiler_tests` filter and require at least one selected test. + +### Backend and target parity + +```bash +cargo test --locked --test jit_tests call_script --features cranelift-jit +cargo test --locked aot_call_script --features cranelift-jit +cargo test --locked -p pd-vm-nostd +cargo test --locked -p pd-vm-wasm +``` + +### Full gates + +```bash +cargo test --locked --workspace --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +### Required observations + +- Every filtered command reports at least one selected test. +- The 32-branch single-file and module fixtures compile and execute. +- The 257-simultaneous-local control still fails before wide locals land. +- Aggregate diagnostics print real data/callable/total counts. +- Direct-only named functions do not consume hidden callable slots. +- Exported and captured named functions retain runtime callable identity. +- No backend silently falls back because it cannot decode `CallScript`. +- Worktree is clean after each scoped commit. + +## Stop conditions + +Stop and report the exact blocker before continuing if any of these occurs: + +- Removing named-call footprint propagation breaks capture ownership that cannot be represented through existing frame/cell metadata. +- `CallScript` requires a second call-frame implementation instead of reusing the existing callable entry helper. +- A backend cannot preserve return/deopt/drop/cancellation behavior for direct calls without a private executor or compatibility side channel. +- Selective materialization changes exported callable identity or public embedding behavior without an explicit API decision. +- Intermediate commits cannot pass their focused default-feature gates. + +## Target criteria + +- Known named callees no longer inflate caller live sets with callee body locals. +- Locals from separate script frames can reuse relative slot numbers. +- Dynamic callable and capture paths retain conservative correctness. +- The storage-shaped 32-branch fixture remains below 256 slots without wide bytecode. +- Aggregate overflow reports actual counts, never slot 65535 as a placeholder. +- Direct-only non-capturing functions use `CallScript` and allocate no hidden callable local. +- Functions requiring value identity/environment/export remain materialized and use `CallValue` where appropriate. +- Interpreter, JIT, AOT, no-std, debugger, REPL, wasm, and VMBC agree on direct-call semantics. +- Genuine same-frame pressure beyond 256 remains rejected until the wide-local plan is implemented. diff --git a/plans/2026-08-11_wide-local-bytecode.md b/plans/2026-08-11_wide-local-bytecode.md new file mode 100644 index 00000000..90098fc1 --- /dev/null +++ b/plans/2026-08-11_wide-local-bytecode.md @@ -0,0 +1,823 @@ +# Wide Local Bytecode Implementation Plan + +**Goal:** Support frames with up to 65,536 addressable local slots by adding wide local load/store instructions while preserving the compact one-byte encoding for slots 0 through 255. + +**Architecture:** Keep `LocalSlot = u16` as the compiler/runtime semantic index. Preserve existing short `Ldloc` and `Stloc` opcode values and add `LdlocWide`/`StlocWide` with little-endian `u16` operands. Decode both forms immediately into one normalized `u16` slot type, then carry that type through debug metadata, VMBC, interpreter, JIT, AOT, native bridges, CLI tooling, wasm analysis, and `pd-vm-nostd`. + +**Tech Stack:** Rust 2024, RustScript compiler and allocator, bytecode assembler/decoder, VMBC, interpreter, Trace JIT/Cranelift native backend, AOT artifacts, debugger/REPL, wasm analyzer, and `pd-vm-nostd`. + +--- + +## Status and dependency + +- Status: proposed. +- Depends on `2026-08-11_frame-aware-local-allocation.md`. +- Begin only after named-call liveness no longer counts separate callee frames as simultaneous local pressure. +- Wide encoding addresses genuine same-frame pressure. It must not become a workaround for stale liveness or unconditional hidden callable allocation. +- The dependency plan reserves `CallScript = 0x1A` and VMBC V12. This plan therefore reserves `LdlocWide = 0x1B`, `StlocWide = 0x1C`, and VMBC V13. Recheck the opcode table and current wire constants at implementation start; if another accepted plan has consumed those values, update this plan and all fixtures before writing code. + +## Current baseline + +The current tree has mixed local-index widths: + +| Surface | Current representation | +| --- | --- | +| Frontend/compiler slot | `LocalSlot = u16` | +| Program/frame local count | vector length / `usize` | +| Short bytecode local operand | `u8` | +| `Assembler::ldloc/stloc` | `u8` | +| `DebugInfo::LocalInfo.index` | `u8` | +| Interpreter local helpers | primarily `u8` | +| Trace JIT local/source metadata | primarily `u8` | +| AOT IR/provenance | primarily `u8` | +| `pd-vm-nostd` local helpers | `u8` | +| VMBC baseline after dependency plan | V12 | + +Current codegen rejects an aggregate frame above 256 before debug registration or bytecode emission. The allocator also uses a 256-entry used-color set. Merely widening one emission helper would therefore leave earlier compiler gates and downstream decoders inconsistent. + +## Encoding contract + +After the dependency plan, the opcode table is expected to contain: + +```text +CallValue = 0x19 +CallScript = 0x1A +LdlocWide = 0x1B +StlocWide = 0x1C +``` + +Local access encoding: + +```text +slot 0..=255 + Ldloc + Stloc + +slot 256..=65535 + LdlocWide + StlocWide +``` + +Rules: + +- Existing `Ldloc`/`Stloc` opcode values and operand widths never change. +- Wide instructions always carry exactly two operand bytes. +- Compiler emission always chooses the shortest valid encoding. +- Decoders normalize either form to `u16` before semantic processing. +- Slot 255 uses short form; slot 256 uses wide form. +- Local count is one greater than the largest referenced slot, subject to frame metadata and parameter/capture slots. +- The maximum valid slot index is 65,535; the maximum valid local count is 65,536. +- Truncated wide operands return typed bounds/validation errors and never read a partial index. +- Branch offsets remain byte offsets and account for the wider instruction length. + +## Compatibility contract + +### Raw bytecode + +- Existing short instruction bytes remain unchanged. +- Old runtimes reject unknown wide opcodes; they must not reinterpret operands as independent instructions. +- New runtimes decode both short and wide forms. + +### VMBC + +- V12 remains the encoding for programs whose bytecode and debug metadata contain only short local indices. +- V13 is required when bytecode contains `LdlocWide`/`StlocWide` or any debug local index exceeds 255. +- The V13 decoder accepts both short and wide instructions. +- The new decoder accepts both V12 and V13. +- V12 debug local indices remain one byte; V13 debug local indices are little-endian `u16`. +- The encoder selects the minimum compatible version from validated program contents; callers do not choose it manually. +- Short-only V12 output remains byte-for-byte identical to the baseline after the dependency plan. + +### AOT/native artifacts + +- Increment AOT artifact format and callable/native ABI revisions once. +- Mark wide-local artifacts with an explicit supported flag or revision so an older loader rejects them before execution. +- Do not make artifact consumers infer wide-local support from frame length alone. +- Regenerate checked-in artifact fixtures under the new declared version policy. + +## Semantic invariants + +- Move-by-default `Ldloc`, copy/borrow behavior, and capture-cell routing remain unchanged. +- `LdlocWide` has exactly the same ownership semantics as `Ldloc`. +- `StlocWide` has exactly the same replacement/drop semantics as `Stloc`. +- Frame-relative addressing and `local_base` remain the only way to obtain an absolute local index. +- Parameters, captures, hidden materialized callables, debugger locals, deopt restoration, and AOT exits can all address slots above 255. +- Short programs do not pay an extra byte per local access. +- Optimizers may initially decline wide-local frames using an explicit reason, but every scanner must decode instruction boundaries correctly. +- No backend may silently truncate `u16` to `u8`. + +## Scope boundary + +### In scope + +- Dynamic graph-color bookkeeping up to `LocalSlot::MAX`. +- Additive wide load/store opcodes. +- Slot-aware assembler/compiler APIs. +- Debug metadata and lookup widening. +- VMBC V12/V13 dual decoding and version selection. +- Interpreter and no-std wide execution. +- JIT scanner/recorder/native index normalization and explicit admission behavior. +- AOT IR, SSA, lowering, runtime restoration, and artifact compatibility. +- REPL/debugger/wasm bytecode scanning and local synchronization. +- Tests using roughly 300 genuinely simultaneous locals in one frame. + +### Out of scope + +- Local indices wider than `u16`. +- Wide constant, host call, prototype, argument-count, branch, stack, or capture-ID operands. +- Replacing the bytecode ISA with LEB128 or a generic prefix encoding. +- Changing short opcode values or widening their operands in place. +- Changing frame-aware liveness or named callable materialization; those belong to the dependency plan. +- Removing existing JIT profitability limits solely to make a 300-local trace compile natively. +- Agent-specific source changes or compiler exceptions. +- A new VMBC migration service or automatic rewriting of external stored artifacts. + +## Shared implementation type + +Introduce or standardize one normalized runtime-facing type: + +```rust +pub type RuntimeLocalIndex = u16; +``` + +A newtype is acceptable if it reduces accidental narrowing: + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RuntimeLocalIndex(u16); +``` + +Selection criteria: + +- Prefer a newtype if it can be introduced without large public API churn. +- Provide `as_usize()` and checked constructors from `usize`. +- Do not expose unchecked `as u8` conversions. +- `LocalSlot` may remain the compiler IR type; define an explicit conversion at the codegen boundary if compiler and runtime types stay separate. + +## Milestone 1: Add boundary and compatibility tests + +**Objective:** Establish RED coverage for slots 255, 256, 299, and 65,535 before changing emission. + +**Files:** + +- Modify: `tests/compiler/compiler_common_tests.rs` +- Modify: `tests/compiler/compiler_rustscript_tests.rs` +- Modify: `tests/vm_tests.rs` or focused files under `tests/vm/` +- Modify: `tests/wire_tests.rs` or focused files under `tests/wire/` +- Modify: `tests/compiler/compiler_common_tests.rs` +- Modify: `tests/wire/assembler_vmbc_edge_tests.rs` +- Modify: `tests/vm/runtime_state_edge_tests.rs` +- Modify: `pd-vm-nostd/tests/` +- Create: generated-source helpers under `tests/common/` if no suitable helper exists + +**Test source design:** + +Generate one function with approximately 300 locals that remain simultaneously live until the final expression. Sequential declarations followed by a final reduction or tuple/array construction are valid only if liveness proves every value remains required. Add an assertion on resulting frame local count so an optimizer cannot accidentally turn the fixture into a low-pressure test. + +**Steps:** + +1. Add a 256-local boundary fixture whose highest referenced slot is 255. Assert short `Ldloc`/`Stloc` emission. +2. Add a 257-local fixture whose highest referenced slot is 256. Current behavior must be RED with the real frame-limit diagnostic from the dependency plan. +3. Add a roughly 300-local fixture that returns a value sourced from a slot above 255. +4. Add a hand-assembled boundary test that emits slot 255 and slot 256 through the future slot-aware assembler API. +5. Add expected decode failures for one-byte and zero-byte truncated wide operands. +6. Add a small-program VMBC golden fixture and record its exact V12 bytes before implementation. +7. Add debugger metadata coverage for a named local at slot 299. +8. Add a true maximum-index structural test for slot 65,535 without allocating an excessive source AST. Use hand assembly/program metadata and a locals vector sized 65,536 only in a bounded test. +9. Add an overflow test for requested local count 65,537 or source slot 65,536, expecting a typed compiler/assembler error. + +**Focused RED command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked --test compiler_tests wide_local +``` + +Expected before implementation: slot-256/high-slot success cases fail at the compiler or assembler boundary; slot-255 and short VMBC baselines pass. + +Do not commit failing tests alone. Commit the tests with the first minimal implementation milestone that makes their scoped subset pass. + +## Milestone 2: Widen allocator bookkeeping without weakening pressure checks + +**Objective:** Allow graph coloring to select physical colors above 255 while retaining the `u16` semantic ceiling. + +**Files:** + +- Modify: `src/compiler/lifetime/liveness.rs` +- Modify: `src/compiler/lifetime/availability.rs` +- Modify: `src/compiler/mod.rs` +- Test: compiler local-compaction and true-live-pressure tests + +**Steps:** + +1. Replace fixed `[bool; 256]` or equivalent used-color storage with a dynamically sized bitset/vector bounded by the compilation unit's candidate local count and 65,536. +2. Keep deterministic first-fit color selection so existing short programs retain their current slot assignment where graph order is unchanged. +3. Convert candidate colors to `LocalSlot` with checked conversion; never wrap an oversized `usize`. +4. Distinguish: + - graph cannot allocate because more than 65,536 colors are genuinely required; + - codegen needs wide bytecode because color exceeds 255; + - aggregate count arithmetic overflow. +5. Remove the 256-color allocator error path only after wide codegen exists in the same passing commit series. +6. Add tests proving: + - 300 overlapping values produce a highest slot at or above 299; + - 300 non-overlapping values still compact into a small short range; + - deterministic source recompilation yields identical slot assignment and bytecode; + - 65,537 overlapping colors produce a typed upper-bound error. +7. Check memory behavior of the allocator with a 65,536-slot synthetic graph. Avoid an unconditional dense 65,536-by-65,536 matrix; adjacency remains sparse or uses existing graph sets. + +**Focused command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked --test compiler_tests local_slot_allocator +``` + +**Commit:** + +```bash +git add src/compiler/lifetime/liveness.rs \ + src/compiler/lifetime/availability.rs src/compiler/mod.rs \ + tests/compiler/compiler_common_tests.rs +git commit -m "feat(compiler): allocate u16 local slots" +``` + +## Milestone 3: Add wide opcodes and slot-aware assembler emission + +**Objective:** Define the additive ISA and preserve existing short assembler APIs. + +**Files:** + +- Modify: `src/bytecode.rs` +- Modify: `src/assembler.rs` +- Modify: assembler/disassembler tests + +**Steps:** + +1. Add `OpCode::LdlocWide = 0x1B` and `OpCode::StlocWide = 0x1C` after confirming `CallScript = 0x1A` from the dependency plan. +2. Return operand length 2 for both wide opcodes. +3. Add little-endian `emit_u16` and checked `read_u16` helpers where a shared helper does not already exist. +4. Preserve: + +```rust +pub fn ldloc(&mut self, index: u8) +pub fn stloc(&mut self, index: u8) +``` + +5. Add slot-oriented APIs: + +```rust +pub fn ldloc_slot(&mut self, index: u16) +pub fn stloc_slot(&mut self, index: u16) +``` + +6. Have slot-oriented APIs emit short form for `index <= 255`, wide form otherwise. +7. Update text assembly and numeric parsing to accept local indices through 65,535 and reject larger/negative values with source-aware errors. +8. Update disassembly and local-count inference to normalize both forms to `u16`. +9. Audit every handwritten bytecode walker. Generic `operand_len()` loops should adapt automatically; fixed `ip += 2` assumptions require explicit changes. +10. Add exact byte tests: + +```text +ldloc_slot(255) -> [Ldloc, 0xff] +ldloc_slot(256) -> [LdlocWide, 0x00, 0x01] +stloc_slot(65535) -> [StlocWide, 0xff, 0xff] +``` + +11. Confirm old `ldloc(u8)` callers emit unchanged bytes. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked assembler wide_local +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked bytecode wide_local +``` + +Each filter must select at least one test. + +**Commit:** + +```bash +git add src/bytecode.rs src/assembler.rs +git commit -m "feat(bytecode): add wide local access opcodes" +``` + +## Milestone 4: Emit wide locals from codegen and widen debug metadata + +**Objective:** Remove all compiler/debug narrowing before bytecode emission. + +**Files:** + +- Modify: `src/compiler/codegen.rs` +- Modify: `src/debug_info.rs` +- Modify: `src/compiler/diagnostics.rs` +- Modify: debug metadata consumers in `src/vmbc.rs`, `src/cli.rs`, and `pd-vm-wasm/src/analyzer.rs` +- Modify: compiler and debugger tests + +**Steps:** + +1. Replace `u8::try_from(LocalSlot)` gates in local load/store emission with `Assembler::ldloc_slot` and `stloc_slot`. +2. Keep aggregate frame validation at `<= 65,536`; update the diagnostic maximum accordingly. +3. Change `LocalInfo.index` to `u16` or the normalized local-index newtype. +4. Change debug builder methods and lookups to accept the widened type. +5. Change `DebugInfo::local_index` to return `Option`. If public source compatibility must be preserved, add a clearly named checked short helper rather than truncating: + +```rust +pub fn local_index_u8(&self, name: &str) -> Option +``` + +6. Update compiler debug registration before emission so a local above 255 never fails in metadata first. +7. Update local range/lifetime metadata and debugger rendering for high slots. +8. Add tests for source name lookup, declared/last line ranges, breakpoints, and local display at slot 299. +9. Verify slot 255 still uses short bytecode even though debug metadata is now `u16`. +10. Search the compiler and debugger trees for every `local as u8`, `index: u8`, and `Option` tied to locals. Replace or justify each occurrence in the plan execution notes. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked --test compiler_tests wide_local +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked --test compiler_tests debug_info +``` + +**Commit:** + +```bash +git add src/compiler/codegen.rs src/compiler/diagnostics.rs \ + src/debug_info.rs src/vmbc.rs src/cli.rs pd-vm-wasm/src/analyzer.rs \ + tests/compiler/compiler_common_tests.rs \ + tests/wire/assembler_vmbc_edge_tests.rs \ + tests/vm/runtime_state_edge_tests.rs +git commit -m "feat(compiler): emit and describe wide locals" +``` + +Stage exact files during implementation. + +## Milestone 5: Execute wide locals in the interpreter + +**Objective:** Make the primary VM execute short and wide local operations through one semantic path. + +**Files:** + +- Modify: `src/vm/mod.rs` +- Modify: `src/vm/instance.rs` if local helpers are owned there +- Modify: `src/vm/superinstructions.rs` +- Test: VM, capture, drop-contract, suspension, and recursion tests + +**Steps:** + +1. Normalize decoded local indices to `u16` immediately: + +```rust +OpCode::Ldloc => read_u8().map(u16::from) +OpCode::LdlocWide => read_u16_le() +``` + +2. Change internal `absolute_local`, load, store, capture-cell lookup, and error constructors to accept `u16` or the normalized newtype. +3. Retain public `set_local(u8, ...)` where useful and add `set_local_slot(u16, ...)`; route both through one implementation. +4. Keep frame-base addition checked in `usize` and verify the result lies within the active frame, not merely within the whole locals vector. +5. Route short and wide load through the same ownership/capture-cell helper. +6. Route short and wide store through the same replacement/drop/capture-cell helper. +7. Let wide operations bypass existing short-only superinstructions initially. Update fusion scanners so they skip the correct width and never fuse across a wide operand. +8. Add interpreter tests for: + - moved and copied high-slot values; + - store replacement/drop at slot 299; + - borrowed capture cell at a high slot; + - nested frames where caller and callee both use high slots; + - yield/wait/resume with a high slot live; + - invalid high slot relative to active frame; + - truncated operand errors. +9. Execute the generated 300-live-local program and assert its final value. + +**Focused command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked --test vm_tests wide_local +``` + +**Commit:** + +```bash +git add src/vm/mod.rs src/vm/instance.rs \ + src/vm/superinstructions.rs tests/vm/ +git commit -m "feat(vm): execute wide local accesses" +``` + +## Milestone 6: Add VMBC V13 with dual-version decoding + +**Objective:** Serialize wide bytecode/debug metadata without changing short-only V12 artifacts. + +**Files:** + +- Modify: `src/vmbc.rs` +- Modify: VMBC validators/disassemblers in `src/` +- Modify: `tests/wire_tests.rs` and wire fixtures +- Modify later in parity: `pd-vm-nostd/src/vmbc.rs` + +**Steps:** + +1. Define `VERSION_V12` and `VERSION_V13`; stop using a single accepted-version equality check. +2. Add a validated program scan that determines whether wide bytecode or wide debug indices exist. +3. Encode V12 when all local accesses/debug entries fit short form; encode V13 otherwise. +4. Decode V12 using one-byte debug local indices and reject wide opcodes. +5. Decode V13 using `u16` debug local indices and accept both short and wide local opcodes. +6. Validate instruction boundaries before deriving local count. +7. Infer local count using normalized `u16`; do not cap inferred count at 256. +8. Ensure frame/prototype local counts can represent 65,536 even though the highest index is `u16::MAX`. +9. Add tests for: + - byte-identical short-only V12 golden artifact; + - V13 round trip with slot 256 and debug slot 299; + - V13 containing only short access instructions but a wide debug index; + - V12 rejection of wide opcodes; + - old/unknown version rejection; + - truncated wide bytecode; + - count overflow/inconsistent frame metadata; + - disassembly showing 256/299 exactly. +10. Document the version decision near constants and wire format docs. + +**Focused command:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked --test wire_tests wide_local +``` + +**Commit:** + +```bash +git add src/vmbc.rs tests/wire_tests.rs tests/fixtures/ +git commit -m "feat(vmbc): encode wide local metadata in v13" +``` + +Stage exact fixture paths during implementation. + +## Milestone 7: Mirror wide locals in `pd-vm-nostd` + +**Objective:** Decode and execute the same V12/V13 contract without allocation-dependent shortcuts. + +**Files:** + +- Modify: `pd-vm-nostd/src/program.rs` +- Modify: `pd-vm-nostd/src/vm.rs` +- Modify: `pd-vm-nostd/src/error.rs` +- Modify: `pd-vm-nostd/src/vmbc.rs` +- Modify: `pd-vm-nostd/tests/` + +**Steps:** + +1. Mirror opcode values and operand lengths exactly. +2. Widen local-count inference to `usize` with normalized `u16` indices. +3. Add `read_u16_le` without unaligned pointer reads. +4. Change internal absolute/store/load helpers to accept `u16`. +5. Preserve public short APIs and add slot-aware alternatives where needed. +6. Decode both V12 and V13 under the same rules as std VMBC. +7. Execute a V13 fixture generated by the std encoder; do not duplicate a hand-maintained fixture if the test harness can share bytes. +8. Add no-std tests for slot 255, 256, 299, invalid frame-relative access, and truncated operands. +9. Verify feature-minimal/no-default-feature builds if supported by the crate. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked -p pd-vm-nostd wide_local +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo check --locked -p pd-vm-nostd --no-default-features +``` + +**Commit:** + +```bash +git add pd-vm-nostd/src/ pd-vm-nostd/tests/ +git commit -m "feat(nostd): execute v13 wide locals" +``` + +Stage exact files during implementation. + +## Milestone 8: Make Trace JIT and native scanners width-safe + +**Objective:** Prevent trace/JIT corruption and carry normalized indices even when policy declines a wide frame. + +**Files:** + +- Modify: `src/vm/jit/trace.rs` +- Modify: `src/vm/jit/recorder.rs` +- Modify: `src/vm/jit/inline.rs` +- Modify: `src/vm/jit/deopt.rs` +- Modify: `src/vm/jit/native/` +- Modify: `src/vm/native/bridge.rs` +- Modify: `src/vm/native/mod.rs` +- Test: JIT trace scanner, recorder, native bridge, and deopt tests + +**Steps:** + +1. Update all trace/header/inlining scanners to recognize two-byte wide operands and preserve instruction boundaries. +2. Change `source_local`, recorded `Ldloc/Stloc` indices, symbolic local maps, invalid-local errors, and native helper parameters from `u8` to `u16` or the normalized type. +3. Remove `enumerate() as u8` and equivalent narrowing in restoration/bridge code. +4. Keep existing profitability/admission limits such as `MAX_PROFITABLE_FRAME_LOCALS` unless an independent benchmark justifies changing them. +5. If a frame above that limit is declined, return an explicit admission reason after valid decoding. Do not report malformed bytecode or terminate scanning at `LdlocWide`. +6. For test-only traces that contain a wide instruction within an otherwise accepted shape, either: + - lower the wide index end to end; or + - return a typed unsupported/admission reason before native execution. + + Silent interpreter fallback from an unknown opcode is prohibited. +7. If wide traces are accepted, widen native local address calculations and deopt state maps; verify slot 299 is restored exactly. +8. Increment `NATIVE_CALLABLE_ABI_VERSION` once if helper signatures or persisted native metadata change. +9. Add tests for: + - scanner skips 2-byte operand; + - branch target after a wide instruction; + - inline classifier reports an intentional reason; + - recorder preserves index 299; + - deopt/native exit restoration does not truncate; + - short trace behavior remains unchanged. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked --test jit_tests wide_local --features cranelift-jit +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked native wide_local --features cranelift-jit +``` + +**Commit:** + +```bash +git add src/vm/jit/ src/vm/native/ tests/jit/ +git commit -m "feat(jit): decode and track wide local slots" +``` + +Stage exact files during implementation. + +## Milestone 9: Widen AOT IR, lowering, restoration, and artifacts + +**Objective:** Carry high local indices through AOT analysis and generated code without truncation. + +**Files:** + +- Modify: `src/vm/aot/cfg.rs` +- Modify: `src/vm/aot/ir.rs` +- Modify: `src/vm/aot/ssa.rs` +- Modify: `src/vm/aot/compile.rs` +- Modify: `src/vm/aot/runtime.rs` +- Modify: `src/vm/aot/artifact.rs` +- Modify: AOT tests and artifact fixtures + +**Steps:** + +1. Decode short/wide operands into `u16` in AOT IR lowering. +2. Change `AotInstruction::{Ldloc, LdlocOwned, Stloc}`, stack provenance, delayed-move analysis, local-null maps, SSA local IDs, errors, and runtime helper arguments to `u16`/`usize` as appropriate. +3. Update optimization patterns that compare adjacent `Ldloc`/`Stloc`; they must decode semantic indices rather than assume a fixed byte offset. +4. Verify CFG block boundaries and branch targets around three-byte wide instructions. +5. Widen AOT exit/deopt/restoration metadata and never narrow an enumerated local index. +6. Emit native address calculations from normalized indices and checked frame bases. +7. Increment AOT artifact version and ABI once; add `FLAG_WIDE_LOCALS` or equivalent declared compatibility capability. +8. Reject a wide artifact in a loader that lacks the flag/revision before mapping/executing code. +9. Add tests for: + - AOT IR contains index 299; + - delayed move from/to high slots; + - SSA block parameters include high locals; + - native AOT execution returns a high-slot value; + - exit restoration preserves slot 299; + - artifact round trip with wide flag; + - short artifact compatibility/golden policy; + - malformed artifact and unsupported flags. +10. Prefix AOT execution/lowering tests with `aot_wide_local` and artifact compatibility tests with `wide_local_artifact` so focused commands select the intended unit tests. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked aot_wide_local --features cranelift-jit +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked wide_local_artifact --features cranelift-jit +``` + +**Commit:** + +```bash +git add src/vm/aot/ +git commit -m "feat(aot): lower and restore wide local slots" +``` + +Stage exact files during implementation. + +## Milestone 10: Update CLI, debugger, REPL, wasm, and remaining consumers + +**Objective:** Remove every fixed-width local assumption outside execution backends. + +**Files:** + +- Modify: `src/cli.rs` +- Modify: `src/debug_info.rs` +- Modify: debug consumers in `src/vmbc.rs` and `tests/vm/runtime_state_edge_tests.rs` +- Modify: `pd-vm-wasm/src/analyzer.rs` +- Modify: any disassembler/formatter/validator found by the audit +- Modify: corresponding tests + +**Steps:** + +1. Replace REPL move tracking keyed by `u8` with normalized local indices. +2. Remove fixed instruction-offset assumptions for local loads/stores. +3. Update debugger local lookup/display and breakpoint stepping through wide instructions. +4. Update wasm analyzer validation, instruction counts, disassembly, and local display. +5. Audit repository-wide occurrences using searches for: + +```text +index: u8 +local_index: u8 +source_local: Option +OpCode::Ldloc | OpCode::Stloc +read_u8(...local...) +as u8 +``` + +6. Classify every remaining occurrence: + - argument positions may legitimately remain `u8`; + - host call argument counts may remain `u8`; + - local-slot occurrences must widen or use checked short compatibility APIs. +7. Add tests for REPL/debugger/wasm inspection of slot 299 and for instruction stepping after a wide local access. +8. Update bytecode and VMBC documentation with opcode values, widths, endianness, and version rules. + +**Focused commands:** + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked cli wide_local +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked --test compiler_tests debug_info +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo test --locked -p pd-vm-wasm wide_local +``` + +**Commit:** + +```bash +git add src/cli.rs src/debug_info.rs src/vmbc.rs \ + tests/vm/runtime_state_edge_tests.rs pd-vm-wasm/src/ docs/ +git commit -m "feat(tooling): inspect wide local bytecode" +``` + +Stage exact files during implementation. + +## Milestone 11: Compatibility and performance audit + +**Objective:** Prove short programs remain compact and wide support does not introduce unbounded compiler/runtime cost. + +**Files:** + +- Modify: benchmark or size-test files under `benches/` if present +- Modify: wire golden fixtures +- Modify: allocator stress tests +- Modify: documentation + +**Steps:** + +1. Compile a representative short-program corpus before/after and compare raw bytecode bytes and VMBC V12 artifacts. +2. Assert every local access under 256 still emits the short opcode. +3. Measure code-size delta for the 300-live-local fixture; only accesses to slots above 255 should grow by one byte. +4. Measure allocator memory/time at 300, 4,096, and a bounded 65,536-slot synthetic case. Record values without adding a brittle timing assertion. +5. Ensure no dense quadratic allocation is introduced for the maximum slot domain. +6. Verify error formatting uses actual counts and maximum 65,536. +7. Verify old V12 fixtures decode in std and no-std. +8. Verify V13 wide fixtures reject cleanly in a V12-only reference decoder if one is retained for tests. +9. Confirm native/AOT cache keys and artifact revisions separate old and new semantics. + +**Commit:** + +```bash +git add benches/ tests/fixtures/ docs/ +git commit -m "test(vm): lock wide-local compatibility boundaries" +``` + +Stage only paths actually changed. + +## Verification matrix + +Use one isolated target directory: + +```bash +export CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target +``` + +### Compiler and ISA boundaries + +```bash +cargo fmt --all -- --check +cargo test --locked --test compiler_tests wide_local +cargo test --locked assembler wide_local +cargo test --locked bytecode wide_local +cargo test --locked --test compiler_tests debug_info +``` + +Required assertions: + +- slot 255 emits short form; +- slot 256 emits wide form; +- slot 299 compiles, executes, and appears in debug metadata; +- slot 65,535 can be represented structurally; +- local count 65,537 fails with a typed upper-bound error; +- 300 genuinely simultaneous locals do not compact below the expected pressure range. + +### Wire compatibility + +```bash +cargo test --locked --test wire_tests wide_local +cargo test --locked --test wire_tests v12 +cargo test --locked --test wire_tests v13 +``` + +Required assertions: + +- short-only artifacts remain V12 and byte-identical; +- wide bytecode or debug metadata selects V13; +- new decoder accepts V12 and V13; +- V12 rejects wide opcodes; +- truncated wide operands and inconsistent local counts fail deterministically. + +### Runtime ownership and frame behavior + +```bash +cargo test --locked --test vm_tests wide_local +cargo test --locked --test vm_tests drop_contract +cargo test --locked --test compiler_tests closure +cargo test --locked --test compiler_tests recursion +``` + +Required assertions: + +- move/copy/store/drop behavior matches short instructions; +- capture cells and nested frame bases handle high slots; +- yield/resume and callable return preserve high locals; +- no absolute index can escape the active frame. + +### Backend parity + +```bash +cargo test --locked --test jit_tests wide_local --features cranelift-jit +cargo test --locked aot_wide_local --features cranelift-jit +cargo test --locked wide_local_artifact --features cranelift-jit +cargo test --locked -p pd-vm-nostd wide_local +cargo test --locked -p pd-vm-wasm wide_local +``` + +Required assertions: + +- all scanners preserve instruction boundaries; +- JIT either handles high indices or returns an explicit admission reason; +- AOT IR/native restoration preserves high indices; +- std/no-std decode and execute the same V13 fixture; +- wasm/debug tooling prints exact indices. + +### Full gates + +```bash +cargo test --locked --workspace --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +git diff --check +``` + +Every filtered command must select at least one test. A command reporting zero selected tests is not evidence. + +## Manual verification artifact + +Generate a deterministic RSS program under `/mnt/TEMP/rustscript/wide-local-verification/` with approximately 300 simultaneously live values and a final expected scalar. Verify: + +```bash +CARGO_TARGET_DIR=/mnt/TEMP/rustscript/wide-local-target \ + cargo run --locked --bin pd-vm-run -- \ + /mnt/TEMP/rustscript/wide-local-verification/main.rss +``` + +Expected: successful execution with the known scalar result. + +Then compile/encode the same program to VMBC through the repository's supported CLI/API, decode it with std and no-std tests, disassemble it, and confirm at least one `LdlocWide` or `StlocWide` references slot 256 or above. Remove the generated source and temporary target directory after final verification. + +## Stop conditions + +Stop and report the exact blocker before continuing if any of these occurs: + +- Frame-aware allocation has not landed, or the 300-local fixture's pressure comes from cross-frame over-allocation. +- Supporting wide locals requires changing existing short opcode widths or values. +- Any decoder can desynchronize after a truncated wide operand. +- Debug metadata still narrows before bytecode emission. +- A backend silently truncates or silently falls back after encountering a wide opcode. +- VMBC V12 output changes for short-only programs without an explicit approved reason. +- The allocator requires a dense 65,536-square interference matrix. +- AOT/native restoration cannot represent high indices without an undeclared artifact or ABI change. +- Intermediate milestones cannot pass their focused default-feature gates. + +## Target criteria + +- The compiler supports genuinely simultaneous frame-local pressure through 65,536 slots. +- Slots 0 through 255 retain existing compact bytecode. +- Slots 256 through 65,535 use additive little-endian wide instructions. +- All semantic consumers normalize local indices to `u16` without truncation. +- Debug metadata and tooling expose exact high-slot indices. +- VMBC V12 remains byte-compatible for short-only programs; V13 carries wide code/debug metadata. +- Interpreter and no-std execute a shared wide fixture identically. +- JIT scanners handle wide instruction boundaries and expose explicit policy when declining large frames. +- AOT lowering, execution, restoration, and artifacts preserve high indices. +- Ownership, capture, recursion, suspension, drop, and frame isolation match short-local behavior. +- No compiler or runtime component treats 256 as the semantic maximum after this plan lands. diff --git a/src/assembler.rs b/src/assembler.rs index 3fd5572c..fabcf273 100644 --- a/src/assembler.rs +++ b/src/assembler.rs @@ -303,6 +303,11 @@ impl Assembler { self.emit_opcode(OpCode::CallValue); self.emit_u8(argc); } + pub fn call_script(&mut self, prototype_id: u32, argc: u8) { + self.emit_opcode(OpCode::CallScript); + self.emit_u32(prototype_id); + self.emit_u8(argc); + } pub fn shl(&mut self) { self.emit_opcode(OpCode::Shl); @@ -451,6 +456,11 @@ impl BytecodeBuilder { self.emit_opcode(OpCode::CallValue); self.emit_u8(argc); } + pub fn call_script(&mut self, prototype_id: u32, argc: u8) { + self.emit_opcode(OpCode::CallScript); + self.emit_u32(prototype_id); + self.emit_u8(argc); + } pub fn shl(&mut self) { self.emit_opcode(OpCode::Shl); @@ -748,6 +758,12 @@ pub fn assemble(source: &str) -> Result { let argc = parse_u8(next_token(&mut parts, line_no, "arg count")?, line_no)?; assembler.call_value(argc); } + OpCode::CallScript => { + let prototype_id = + parse_u32(next_token(&mut parts, line_no, "prototype id")?, line_no)?; + let argc = parse_u8(next_token(&mut parts, line_no, "arg count")?, line_no)?; + assembler.call_script(prototype_id, argc); + } OpCode::Shl => assembler.shl(), OpCode::Shr => assembler.shr(), OpCode::Lshr => assembler.lshr(), @@ -805,6 +821,12 @@ fn parse_u16(token: &str, line_no: usize) -> Result { message: format!("invalid u16 '{token}'"), }) } +fn parse_u32(token: &str, line_no: usize) -> Result { + token.parse::().map_err(|_| AsmParseError { + line: line_no, + message: format!("invalid u32 '{token}'"), + }) +} fn parse_f64(token: &str, line_no: usize, what: &str) -> Result { token.parse::().map_err(|_| AsmParseError { diff --git a/src/builtins/runtime/cancellation.rs b/src/builtins/runtime/cancellation.rs new file mode 100644 index 00000000..85b53c16 --- /dev/null +++ b/src/builtins/runtime/cancellation.rs @@ -0,0 +1,1009 @@ +use std::collections::HashMap; +use std::fmt; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Instant; + +use super::error::{RuntimeError, RuntimeErrorCode, RuntimeResult}; +use super::resource::ResourceHandle; + +pub const DEFAULT_MAX_PENDING_OPERATIONS: usize = 64; +const TERMINAL_BIT: u8 = 0x80; +const REASON_MASK: u8 = !TERMINAL_BIT; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct OperationId(u64); + +impl OperationId { + pub fn from_raw(raw: u64) -> RuntimeResult { + if raw == 0 { + return Err(RuntimeError::new( + RuntimeErrorCode::OperationIdExhausted, + "runtime::operation", + "operation id zero is reserved", + )); + } + Ok(Self(raw)) + } + + pub const fn raw(self) -> u64 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum OperationOwner { + HostBridge, + Io, + Http, + #[cfg(feature = "sqlite")] + Sqlite, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum CancellationReason { + Requested = 1, + Deadline = 2, + VmReset = 3, + Parent = 4, + ResourceClosed = 5, +} + +impl CancellationReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::Requested => "requested", + Self::Deadline => "deadline", + Self::VmReset => "vm_reset", + Self::Parent => "parent", + Self::ResourceClosed => "resource_closed", + } + } + + fn from_raw(raw: u8) -> Option { + match raw { + 1 => Some(Self::Requested), + 2 => Some(Self::Deadline), + 3 => Some(Self::VmReset), + 4 => Some(Self::Parent), + 5 => Some(Self::ResourceClosed), + _ => None, + } + } +} + +struct CancellationSignal { + state: AtomicU8, + deadline: Option, + children: Mutex>>, + propagation_error: Mutex>, +} + +impl CancellationSignal { + fn mark_cancelled(&self, reason: CancellationReason) -> bool { + self.state + .compare_exchange(0, reason as u8, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + fn cancel(&self, reason: CancellationReason) -> (bool, Option) { + let transitioned = self.mark_cancelled(reason); + let mut first_error = None; + if transitioned { + let children = self + .children + .lock() + .expect("cancellation children lock should not be poisoned") + .iter() + .filter_map(Weak::upgrade) + .collect::>(); + for child in children { + if let Err(error) = child.cancel(reason) + && first_error.is_none() + { + first_error = Some(error); + } + } + } + (transitioned, first_error) + } + + fn store_propagation_error(&self, error: Option) { + if let Some(error) = error { + let mut stored = self + .propagation_error + .lock() + .expect("cancellation propagation error lock should not be poisoned"); + if stored.is_none() { + *stored = Some(error); + } + } + } + + fn take_propagation_error(&self) -> Option { + self.propagation_error + .lock() + .expect("cancellation propagation error lock should not be poisoned") + .take() + } + + fn reason(&self) -> Option { + let state = self.state.load(Ordering::Acquire); + if state == 0 + && self + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + let (_, error) = self.cancel(CancellationReason::Deadline); + self.store_propagation_error(error); + } + CancellationReason::from_raw(self.state.load(Ordering::Acquire) & REASON_MASK) + } + + fn finish_success(&self) -> bool { + self.state + .compare_exchange(0, TERMINAL_BIT, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + } + + fn finish_cancelled(&self, requested: CancellationReason) -> CancellationReason { + loop { + let state = self.state.load(Ordering::Acquire); + let reason = CancellationReason::from_raw(state & REASON_MASK).unwrap_or(requested); + if state & TERMINAL_BIT != 0 { + return reason; + } + let terminal = TERMINAL_BIT | reason as u8; + if self + .state + .compare_exchange(state, terminal, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return reason; + } + } + } +} + +#[derive(Clone)] +pub struct CancellationToken { + id: OperationId, + signal: Arc, +} + +impl CancellationToken { + pub(crate) fn root() -> Self { + Self { + id: OperationId(u64::MAX), + signal: Arc::new(CancellationSignal { + state: AtomicU8::new(0), + deadline: None, + children: Mutex::new(Vec::new()), + propagation_error: Mutex::new(None), + }), + } + } + + pub fn is_cancelled(&self) -> bool { + self.reason().is_some() + } + + pub fn reason(&self) -> Option { + self.signal.reason() + } + + pub fn cancel(&self, reason: CancellationReason) -> bool { + let (transitioned, error) = self.signal.cancel(reason); + self.signal.store_propagation_error(error); + transitioned + } + + pub(crate) fn take_propagation_error(&self) -> Option { + self.signal.take_propagation_error() + } + + pub(crate) fn mark_cancelled(&self, reason: CancellationReason) -> bool { + self.signal.mark_cancelled(reason) + } + + pub fn check(&self) -> RuntimeResult<()> { + let Some(reason) = self.reason() else { + return Ok(()); + }; + Err(RuntimeError::new( + RuntimeErrorCode::OperationCancelled, + "runtime::operation", + format!( + "operation {} was cancelled ({})", + self.id.raw(), + reason.as_str() + ), + ) + .with_value(self.id.raw())) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OperationStatus { + Pending, + Completed, + Cancelled(CancellationReason), + Failed(RuntimeError), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OperationEnd { + Completed, + Cancelled(CancellationReason), + Failed(RuntimeError), +} + +pub type OperationCleanup = Box RuntimeResult<()> + Send + 'static>; + +struct OperationInner { + status: OperationStatus, + cleanup: Option, + payload: Option, + resource: Option, +} + +struct RegistryInner { + operations: Mutex>, +} + +struct OperationCore { + id: OperationId, + owner: OperationOwner, + token: CancellationToken, + inner: Mutex, +} + +impl OperationCore { + fn status(&self) -> OperationStatus { + self.inner + .lock() + .expect("operation state lock should not be poisoned") + .status + .clone() + } + + fn cancel(&self, reason: CancellationReason) -> RuntimeResult { + let _ = self.token.reason(); + let (_, child_error) = self.token.signal.cancel(reason); + let child_error = child_error.or_else(|| self.token.signal.take_propagation_error()); + let cleanup = { + let mut inner = self + .inner + .lock() + .expect("operation state lock should not be poisoned"); + if !matches!(inner.status, OperationStatus::Pending) { + return Ok(false); + } + let reason = self.token.reason().unwrap_or(reason); + let reason = self.token.signal.finish_cancelled(reason); + inner.status = OperationStatus::Cancelled(reason); + (inner.cleanup.take(), reason) + }; + let cleanup_result = if let (Some(cleanup), reason) = cleanup { + cleanup(OperationEnd::Cancelled(reason)).map_err(|error| { + RuntimeError::new( + RuntimeErrorCode::OperationCleanupFailed, + "runtime::operation", + error.to_string(), + ) + .with_value(self.id.raw()) + }) + } else { + Ok(()) + }; + match (child_error, cleanup_result) { + (Some(error), _) => Err(error), + (None, Err(error)) => Err(error), + (None, Ok(())) => Ok(true), + } + } + + fn complete(&self) -> RuntimeResult { + if let Some(reason) = self.token.reason() { + return self.cancel(reason); + } + self.finish(OperationStatus::Completed, OperationEnd::Completed) + } + + fn fail(&self, error: RuntimeError) -> RuntimeResult { + if let Some(reason) = self.token.reason() { + return self.cancel(reason); + } + self.finish( + OperationStatus::Failed(error.clone()), + OperationEnd::Failed(error), + ) + } + + fn finish(&self, status: OperationStatus, end: OperationEnd) -> RuntimeResult { + let (cleanup, end) = { + let mut inner = self + .inner + .lock() + .expect("operation state lock should not be poisoned"); + if !matches!(inner.status, OperationStatus::Pending) { + return Ok(false); + } + let end = if self.token.signal.finish_success() { + inner.status = status; + end + } else { + let reason = self + .token + .reason() + .expect("a failed success transition must carry cancellation"); + let reason = self.token.signal.finish_cancelled(reason); + inner.status = OperationStatus::Cancelled(reason); + OperationEnd::Cancelled(reason) + }; + (inner.cleanup.take(), end) + }; + let cleanup_result = if let Some(cleanup) = cleanup { + cleanup(end).map_err(|error| { + RuntimeError::new( + RuntimeErrorCode::OperationCleanupFailed, + "runtime::operation", + error.to_string(), + ) + .with_value(self.id.raw()) + }) + } else { + Ok(()) + }; + cleanup_result?; + Ok(true) + } + + fn attach_parent(self: &Arc, parent: &CancellationToken) -> RuntimeResult<()> { + { + let mut children = parent + .signal + .children + .lock() + .expect("cancellation children lock should not be poisoned"); + children.retain(|child| child.strong_count() > 0); + children.push(Arc::downgrade(self)); + } + if parent.is_cancelled() { + self.cancel(parent.reason().unwrap_or(CancellationReason::Parent))?; + } + Ok(()) + } +} + +#[derive(Clone)] +pub struct OperationState { + core: Arc, +} + +impl fmt::Debug for OperationState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("OperationState") + .field("id", &self.id()) + .field("owner", &self.owner()) + .field("status", &self.status()) + .finish() + } +} + +impl OperationState { + pub fn id(&self) -> OperationId { + self.core.id + } + + pub fn owner(&self) -> OperationOwner { + self.core.owner + } + + pub fn token(&self) -> CancellationToken { + self.core.token.clone() + } + + pub fn status(&self) -> OperationStatus { + self.core.status() + } + + #[cfg_attr(feature = "async", allow(dead_code))] + pub fn set_payload(&self, payload: ResourceHandle) { + self.core + .inner + .lock() + .expect("operation state lock should not be poisoned") + .payload = Some(payload); + } + + #[cfg(feature = "sqlite")] + pub(crate) fn set_cleanup(&self, cleanup: OperationCleanup) -> RuntimeResult<()> { + let mut inner = self + .core + .inner + .lock() + .expect("operation state lock should not be poisoned"); + if !matches!(inner.status, OperationStatus::Pending) { + return Err(RuntimeError::new( + RuntimeErrorCode::OperationAlreadyFinished, + "runtime::operation", + "cannot attach cleanup to a terminal operation", + ) + .with_value(self.id().raw())); + } + if inner.cleanup.is_some() { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "runtime::operation", + "operation cleanup is already configured", + ) + .with_value(self.id().raw())); + } + inner.cleanup = Some(cleanup); + Ok(()) + } + + pub fn payload(&self) -> Option { + self.core + .inner + .lock() + .expect("operation state lock should not be poisoned") + .payload + } + + #[cfg_attr(feature = "async", allow(dead_code))] + pub fn set_resource(&self, resource: ResourceHandle) { + self.core + .inner + .lock() + .expect("operation state lock should not be poisoned") + .resource = Some(resource); + } + + pub fn resource(&self) -> Option { + self.core + .inner + .lock() + .expect("operation state lock should not be poisoned") + .resource + } + + pub fn cancel(&self, reason: CancellationReason) -> RuntimeResult { + self.core.cancel(reason) + } + + pub fn complete(&self) -> RuntimeResult { + self.core.complete() + } + + pub fn fail(&self, error: RuntimeError) -> RuntimeResult { + self.core.fail(error) + } + + fn build( + id: OperationId, + owner: OperationOwner, + deadline: Option, + cleanup: Option, + ) -> Self { + let token = CancellationToken { + id, + signal: Arc::new(CancellationSignal { + state: AtomicU8::new(0), + deadline, + children: Mutex::new(Vec::new()), + propagation_error: Mutex::new(None), + }), + }; + Self { + core: Arc::new(OperationCore { + id, + owner, + token, + inner: Mutex::new(OperationInner { + status: OperationStatus::Pending, + cleanup, + payload: None, + resource: None, + }), + }), + } + } +} + +pub struct OperationRegistry { + max_pending: usize, + next_id: u64, + last_external_id: u64, + inner: Arc, +} + +impl OperationRegistry { + pub fn with_limit(max_pending: usize) -> RuntimeResult { + if max_pending == 0 { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "runtime::operation", + "operation registry capacity must be positive", + )); + } + Ok(Self { + max_pending, + next_id: 1, + last_external_id: 0, + inner: Arc::new(RegistryInner { + operations: Mutex::new(HashMap::new()), + }), + }) + } + + pub fn active_count(&self) -> usize { + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .values() + .filter(|operation| !matches!(operation.status(), OperationStatus::Cancelled(_))) + .count() + } + + pub(crate) fn allocate_id(&mut self) -> RuntimeResult { + let id = OperationId::from_raw(self.next_id)?; + self.next_id = self.next_id.checked_add(1).ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::OperationIdExhausted, + "runtime::operation", + "operation id space exhausted", + ) + })?; + Ok(id) + } + + #[cfg_attr(feature = "async", allow(dead_code))] + pub fn start_owned( + &mut self, + owner: OperationOwner, + parent: Option<&CancellationToken>, + deadline: Option, + cleanup: Option, + ) -> RuntimeResult { + if self.active_count() >= self.max_pending { + return Err(RuntimeError::new( + RuntimeErrorCode::OperationLimitExceeded, + "runtime::operation", + "pending operation capacity has been reached", + ) + .with_limit(self.max_pending)); + } + let id = self.allocate_id()?; + let operation = OperationState::build(id, owner, deadline, cleanup); + if let Some(parent) = parent { + operation.core.attach_parent(parent)?; + } + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .insert(id, operation.clone()); + Ok(operation) + } + + #[cfg(test)] + pub fn register_external( + &mut self, + id: OperationId, + owner: OperationOwner, + parent: Option<&CancellationToken>, + deadline: Option, + cleanup: Option, + ) -> RuntimeResult { + self.retire_external_id(id)?; + self.register_retired_external(id, owner, parent, deadline, cleanup) + } + + pub(crate) fn retire_external_id(&mut self, id: OperationId) -> RuntimeResult<()> { + if id.raw() <= self.last_external_id { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "runtime::operation", + format!( + "external operation {} is not newer than the last external operation {}", + id.raw(), + self.last_external_id + ), + ) + .with_value(id.raw())); + } + let next_id = id.raw().checked_add(1).ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::OperationIdExhausted, + "runtime::operation", + "operation id space exhausted", + ) + })?; + self.last_external_id = id.raw(); + self.next_id = self.next_id.max(next_id); + Ok(()) + } + + pub(crate) fn register_retired_external( + &mut self, + id: OperationId, + owner: OperationOwner, + parent: Option<&CancellationToken>, + deadline: Option, + cleanup: Option, + ) -> RuntimeResult { + if id.raw() != self.last_external_id { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "runtime::operation", + format!("external operation {} has not just been retired", id.raw()), + ) + .with_value(id.raw())); + } + if self.active_count() >= self.max_pending { + return Err(RuntimeError::new( + RuntimeErrorCode::OperationLimitExceeded, + "runtime::operation", + "pending operation capacity has been reached", + ) + .with_limit(self.max_pending)); + } + let registered = self + .inner + .operations + .lock() + .expect("operation registry lock should not be poisoned"); + if registered.contains_key(&id) { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "runtime::operation", + format!("operation {} is already registered", id.raw()), + ) + .with_value(id.raw())); + } + let operation = OperationState::build(id, owner, deadline, cleanup); + drop(registered); + if let Some(parent) = parent { + operation.core.attach_parent(parent)?; + } + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .insert(id, operation.clone()); + Ok(operation) + } + + pub fn get(&self, id: OperationId) -> RuntimeResult { + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .get(&id) + .cloned() + .ok_or_else(|| operation_not_found(id)) + } + + pub fn operations_by_owner(&self, owner: OperationOwner) -> Vec { + let operations = self.registered_operations(); + operations + .into_iter() + .filter(|operation| operation.owner() == owner) + .collect() + } + + pub fn operations_for_resource(&self, resource: ResourceHandle) -> Vec { + let operations = self.registered_operations(); + operations + .into_iter() + .filter(|operation| operation.resource() == Some(resource)) + .collect() + } + + pub fn cancel(&mut self, id: OperationId, reason: CancellationReason) -> RuntimeResult { + self.take_operation(id)?.cancel(reason) + } + + pub fn complete(&mut self, id: OperationId) -> RuntimeResult { + self.take_operation(id)?.complete() + } + + pub fn fail(&mut self, id: OperationId, error: RuntimeError) -> RuntimeResult { + self.take_operation(id)?.fail(error) + } + + pub fn cancel_all(&mut self, reason: CancellationReason) -> RuntimeResult { + let operations = { + let mut registered = self + .inner + .operations + .lock() + .expect("operation registry lock should not be poisoned"); + std::mem::take(&mut *registered) + }; + let operations = operations.into_values().collect::>(); + for operation in &operations { + operation.token().mark_cancelled(reason); + } + let mut first_error = None; + for operation in &operations { + if let Err(error) = operation.cancel(reason) { + first_error.get_or_insert(error); + } + } + match first_error { + Some(error) => Err(error), + None => Ok(operations + .iter() + .filter(|operation| matches!(operation.status(), OperationStatus::Cancelled(_))) + .count()), + } + } + + fn registered_operations(&self) -> Vec { + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .values() + .cloned() + .collect() + } + + fn take_operation(&mut self, id: OperationId) -> RuntimeResult { + self.inner + .operations + .lock() + .expect("operation registry lock should not be poisoned") + .remove(&id) + .ok_or_else(|| operation_not_found(id)) + } +} + +impl Default for OperationRegistry { + fn default() -> Self { + Self::with_limit(DEFAULT_MAX_PENDING_OPERATIONS) + .expect("default operation registry configuration should be valid") + } +} + +impl Drop for OperationRegistry { + fn drop(&mut self) { + let _ = self.cancel_all(CancellationReason::VmReset); + } +} + +fn operation_not_found(id: OperationId) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::OperationNotFound, + "runtime::operation", + format!("operation {} is not registered", id.raw()), + ) + .with_value(id.raw()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + use super::super::error::{RuntimeError, RuntimeErrorCode}; + use super::{ + CancellationReason, OperationId, OperationOwner, OperationRegistry, OperationStatus, + }; + + #[test] + fn token_reports_the_first_cancellation_reason() { + let mut registry = OperationRegistry::with_limit(4).expect("registry should be valid"); + let operation = registry + .start_owned(OperationOwner::Io, None, None, None) + .expect("operation should start"); + let token = operation.token(); + assert!(token.cancel(CancellationReason::Deadline)); + assert!(!token.cancel(CancellationReason::Parent)); + assert_eq!(token.reason(), Some(CancellationReason::Deadline)); + assert_eq!(operation.status(), OperationStatus::Pending); + } + + #[test] + 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) + .expect("parent should start"); + let child = registry + .start_owned(OperationOwner::Http, Some(&parent.token()), None, None) + .expect("child should start"); + assert!( + parent + .cancel(CancellationReason::Requested) + .expect("parent cancellation should succeed") + ); + assert_eq!(child.token().reason(), Some(CancellationReason::Requested)); + + let deadline_parent = registry + .start_owned(OperationOwner::Io, None, None, None) + .expect("deadline parent should start"); + let expired = registry + .start_owned( + OperationOwner::Io, + Some(&deadline_parent.token()), + Some(Instant::now() - Duration::from_millis(1)), + None, + ) + .expect("deadline child should start"); + assert_eq!(expired.token().reason(), Some(CancellationReason::Deadline)); + } + + #[test] + 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) + .expect("parent should start"); + let child = registry + .start_owned(OperationOwner::Io, Some(&parent.token()), None, None) + .expect("child should start"); + + assert_eq!( + registry + .cancel_all(CancellationReason::VmReset) + .expect("all operations should cancel"), + 2 + ); + assert_eq!( + parent.status(), + OperationStatus::Cancelled(CancellationReason::VmReset) + ); + assert!(matches!( + child.status(), + OperationStatus::Cancelled(CancellationReason::Parent | CancellationReason::VmReset) + )); + assert_eq!(registry.active_count(), 0); + } + + #[test] + fn parent_cancellation_finishes_registered_children_and_releases_capacity() { + let child_cleanup_count = Arc::new(AtomicUsize::new(0)); + 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) + .expect("parent should start"); + let child = registry + .start_owned( + OperationOwner::Io, + Some(&parent.token()), + None, + Some(Box::new(move |end| { + assert_eq!( + end, + super::OperationEnd::Cancelled(CancellationReason::Requested) + ); + cleanup_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + })), + ) + .expect("child should start"); + + assert!( + parent + .cancel(CancellationReason::Requested) + .expect("parent should cancel") + ); + + assert_eq!( + child.status(), + OperationStatus::Cancelled(CancellationReason::Requested) + ); + assert_eq!(child_cleanup_count.load(Ordering::SeqCst), 1); + assert_eq!(registry.active_count(), 0); + assert!(registry.get(child.id()).is_ok()); + registry + .start_owned(OperationOwner::Io, None, None, None) + .expect("parent cancellation should release registry capacity"); + assert!( + !child + .cancel(CancellationReason::Requested) + .expect("child cancellation should remain idempotent") + ); + assert_eq!(child_cleanup_count.load(Ordering::SeqCst), 1); + } + + #[test] + 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) + .expect("parent should start"); + registry + .start_owned( + OperationOwner::Io, + Some(&parent.token()), + None, + Some(Box::new(|_| { + Err(RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "test::cleanup", + "child cleanup failed", + )) + })), + ) + .expect("child should start"); + + let error = parent + .cancel(CancellationReason::Requested) + .expect_err("child cleanup failure should propagate"); + assert_eq!(error.code(), RuntimeErrorCode::OperationCleanupFailed); + } + + #[test] + fn completed_external_operation_ids_cannot_be_reused() { + let mut registry = OperationRegistry::with_limit(2).expect("registry should be valid"); + let id = OperationId::from_raw(7).expect("operation id should be valid"); + registry + .register_external(id, OperationOwner::HostBridge, None, None, None) + .expect("first external operation should register"); + registry + .complete(id) + .expect("external operation should complete"); + + let error = registry + .register_external(id, OperationOwner::HostBridge, None, None, None) + .expect_err("completed external operation id must remain retired"); + assert_eq!(error.code(), RuntimeErrorCode::InvalidConfiguration); + } + + #[test] + fn rejected_external_operation_ids_are_retired() { + let mut registry = OperationRegistry::with_limit(1).expect("registry should be valid"); + let active = registry + .start_owned(OperationOwner::Io, None, None, None) + .expect("capacity should be occupied"); + let id = OperationId::from_raw(7).expect("operation id should be valid"); + let error = registry + .register_external(id, OperationOwner::HostBridge, None, None, None) + .expect_err("external operation should exceed capacity"); + assert_eq!(error.code(), RuntimeErrorCode::OperationLimitExceeded); + registry + .complete(active.id()) + .expect("capacity should be released"); + + let error = registry + .register_external(id, OperationOwner::HostBridge, None, None, None) + .expect_err("rejected external operation id must remain retired"); + assert_eq!(error.code(), RuntimeErrorCode::InvalidConfiguration); + } + + #[test] + 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) + .expect("parent should start"); + + for _ in 0..32 { + let child = registry + .start_owned(OperationOwner::Io, Some(&parent.token()), None, None) + .expect("child should start"); + registry + .complete(child.id()) + .expect("child should complete"); + } + + let live_links = parent + .token() + .signal + .children + .lock() + .expect("children lock") + .len(); + assert!(live_links <= 1, "completed child links should be pruned"); + } +} diff --git a/src/builtins/runtime/context.rs b/src/builtins/runtime/context.rs new file mode 100644 index 00000000..5fa4bfa9 --- /dev/null +++ b/src/builtins/runtime/context.rs @@ -0,0 +1,80 @@ +use super::error::RuntimeResult; +use super::event::EventLimits; + +#[allow(dead_code)] +pub const STREAM_EMIT_NAME: &str = "stream::emit"; + +/// Configuration for one VM/run-scoped invocation stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RuntimeContextConfig { + event_limits: EventLimits, +} + +impl RuntimeContextConfig { + pub const fn new(event_limits: EventLimits) -> Self { + Self { event_limits } + } + + pub const fn event_limits(self) -> EventLimits { + self.event_limits + } +} + +impl Default for RuntimeContextConfig { + fn default() -> Self { + Self::new(EventLimits::default()) + } +} + +/// Run-scoped invocation stream configuration. +/// +/// The context carries only the per-item event bound. Event values are owned by +/// the active invocation's single pending-event slot; there is no ambient +/// input, no embedding event sink, and no sequence or persistence policy here. +pub struct RuntimeContext { + event_limits: EventLimits, +} + +#[allow(dead_code)] +impl RuntimeContext { + pub fn with_config(config: RuntimeContextConfig) -> RuntimeResult { + Ok(Self { + event_limits: config.event_limits(), + }) + } + + pub fn config(&self) -> RuntimeContextConfig { + RuntimeContextConfig::new(self.event_limits) + } + + pub fn event_limits(&self) -> EventLimits { + self.event_limits + } +} + +impl Default for RuntimeContext { + fn default() -> Self { + Self::with_config(RuntimeContextConfig::default()) + .expect("default runtime context configuration should be valid") + } +} + +#[cfg(test)] +mod tests { + use super::{EventLimits, RuntimeContext, RuntimeContextConfig, STREAM_EMIT_NAME}; + + #[test] + fn host_name_is_generic_and_stable() { + assert_eq!(STREAM_EMIT_NAME, "stream::emit"); + assert!(std::mem::size_of::() > 0); + } + + #[test] + fn per_item_event_limits_are_configurable() { + let limits = EventLimits::new(128, 4).expect("limits should be valid"); + let context = RuntimeContext::with_config(RuntimeContextConfig::new(limits)) + .expect("context should be constructible"); + assert_eq!(context.event_limits(), limits); + assert_eq!(context.config().event_limits(), limits); + } +} diff --git a/src/builtins/runtime/context_host.rs b/src/builtins/runtime/context_host.rs new file mode 100644 index 00000000..0cc3ba32 --- /dev/null +++ b/src/builtins/runtime/context_host.rs @@ -0,0 +1,12 @@ +use pd_host_function::pd_host_function; + +use super::AnyValue; +use crate::vm::{CallOutcome, Vm, VmResult}; + +/// Places one bounded event item on the active invocation stream and yields +/// control to the invocation poller. `stream::emit` still evaluates to `()` +/// inside RSS. +#[pd_host_function(name = "stream::emit")] +fn stream_emit_impl(vm: &mut Vm, value: AnyValue) -> VmResult { + vm.emit_stream_item(value) +} diff --git a/src/builtins/runtime/error.rs b/src/builtins/runtime/error.rs new file mode 100644 index 00000000..82ab3c31 --- /dev/null +++ b/src/builtins/runtime/error.rs @@ -0,0 +1,157 @@ +use std::fmt; + +/// Result type used by the generic runtime support modules. +pub type RuntimeResult = Result; + +/// Stable machine-readable categories for runtime capability failures. +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeErrorCode { + InvalidConfiguration, + EventPayloadTooLarge, + EventDepthExceeded, + ResourceLimitExceeded, + InvalidResourceHandle, + ResourceHandleWrongTable, + ResourceTypeMismatch, + ResourceStale, + ResourceAlreadyClosed, + ResourceIdExhausted, + ResourceCleanupFailed, + OperationLimitExceeded, + OperationNotFound, + OperationAlreadyFinished, + OperationCancelled, + OperationFailed, + OperationIdExhausted, + OperationCleanupFailed, +} + +impl RuntimeErrorCode { + pub const fn as_str(self) -> &'static str { + match self { + Self::InvalidConfiguration => "invalid_configuration", + Self::EventPayloadTooLarge => "event_payload_too_large", + Self::EventDepthExceeded => "event_depth_exceeded", + Self::ResourceLimitExceeded => "resource_limit_exceeded", + Self::InvalidResourceHandle => "invalid_resource_handle", + Self::ResourceHandleWrongTable => "resource_handle_wrong_table", + Self::ResourceTypeMismatch => "resource_type_mismatch", + Self::ResourceStale => "resource_stale", + Self::ResourceAlreadyClosed => "resource_already_closed", + Self::ResourceIdExhausted => "resource_id_exhausted", + Self::ResourceCleanupFailed => "resource_cleanup_failed", + Self::OperationLimitExceeded => "operation_limit_exceeded", + Self::OperationNotFound => "operation_not_found", + Self::OperationAlreadyFinished => "operation_already_finished", + Self::OperationCancelled => "operation_cancelled", + Self::OperationFailed => "operation_failed", + Self::OperationIdExhausted => "operation_id_exhausted", + Self::OperationCleanupFailed => "operation_cleanup_failed", + } + } +} + +/// Structured error returned by the generic runtime support modules. +/// +/// The core VM currently exposes `VmError::HostError` as the extension point for host failures. +/// Runtime code keeps the stable category and fields until the parent wiring maps it into that +/// existing VM error variant. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RuntimeError { + code: RuntimeErrorCode, + operation: &'static str, + message: String, + limit: Option, + value: Option, +} + +#[allow(dead_code)] +impl RuntimeError { + pub fn new( + code: RuntimeErrorCode, + operation: &'static str, + message: impl Into, + ) -> Self { + Self { + code, + operation, + message: message.into(), + limit: None, + value: None, + } + } + + pub fn code(&self) -> RuntimeErrorCode { + self.code + } + + pub fn operation(&self) -> &'static str { + self.operation + } + + pub fn message(&self) -> &str { + &self.message + } + + pub fn limit(&self) -> Option { + self.limit + } + + pub fn value(&self) -> Option { + self.value + } + + pub fn with_limit(mut self, limit: usize) -> Self { + self.limit = Some(limit); + self + } + + pub fn with_value(mut self, value: u64) -> Self { + self.value = Some(value); + self + } +} + +impl fmt::Display for RuntimeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "runtime error [{}] in {}: {}", + self.code.as_str(), + self.operation, + self.message + )?; + if let Some(limit) = self.limit { + write!(formatter, " (limit: {limit})")?; + } + if let Some(value) = self.value { + write!(formatter, " (value: {value})")?; + } + Ok(()) + } +} + +impl std::error::Error for RuntimeError {} + +#[cfg(test)] +mod tests { + use super::{RuntimeError, RuntimeErrorCode}; + + #[test] + fn structured_error_preserves_code_and_fields() { + let error = RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + "stream::emit", + "event payload exceeds the configured bound", + ) + .with_limit(32) + .with_value(64); + + assert_eq!(error.code(), RuntimeErrorCode::EventPayloadTooLarge); + assert_eq!(error.operation(), "stream::emit"); + assert_eq!(error.limit(), Some(32)); + assert_eq!(error.value(), Some(64)); + assert!(error.to_string().contains("event_payload_too_large")); + } +} diff --git a/src/builtins/runtime/event.rs b/src/builtins/runtime/event.rs new file mode 100644 index 00000000..c91a95ec --- /dev/null +++ b/src/builtins/runtime/event.rs @@ -0,0 +1,190 @@ +use crate::vm::Value; + +use super::error::{RuntimeError, RuntimeErrorCode, RuntimeResult}; + +pub const DEFAULT_MAX_EVENT_PAYLOAD_BYTES: usize = 64 * 1024; +pub const DEFAULT_MAX_EVENT_DEPTH: usize = 64; + +/// Per-item bounds applied to one `stream::emit(value)` call. +/// +/// The core validates only this per-item value bound before placing the value +/// in the active invocation's single pending-event slot. Sequence assignment, +/// cumulative byte accounting, event receipts, and embedding-owned sinks are +/// not part of the core contract; delivery policy belongs to the embedding. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EventLimits { + max_payload_bytes: usize, + max_depth: usize, +} + +#[allow(dead_code)] +impl EventLimits { + pub fn new(max_payload_bytes: usize, max_depth: usize) -> RuntimeResult { + if max_payload_bytes == 0 || max_depth == 0 { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "stream::emit", + "event payload and depth limits must be positive", + )); + } + Ok(Self { + max_payload_bytes, + max_depth, + }) + } + + pub const fn max_payload_bytes(self) -> usize { + self.max_payload_bytes + } + + pub const fn max_depth(self) -> usize { + self.max_depth + } +} + +impl Default for EventLimits { + fn default() -> Self { + Self { + max_payload_bytes: DEFAULT_MAX_EVENT_PAYLOAD_BYTES, + max_depth: DEFAULT_MAX_EVENT_DEPTH, + } + } +} + +/// An event value whose per-item bound has been validated. +#[derive(Clone, Debug, PartialEq)] +pub struct EventPayload { + value: Value, + size_bytes: usize, +} + +impl EventPayload { + pub fn try_new(value: Value, limits: EventLimits) -> RuntimeResult { + let size_bytes = estimate_value_size(&value, limits)?; + Ok(Self { value, size_bytes }) + } + + #[allow(dead_code)] + pub fn size_bytes(&self) -> usize { + self.size_bytes + } + + pub fn into_value(self) -> Value { + self.value + } +} + +/// Estimates the bounded representation size of a value. +/// +/// The estimate is deliberately independent of serialization formats. It counts scalar tags, +/// container headers, string/byte contents, and recursively contained values. The host transport +/// can apply a stricter byte limit when it serializes the validated value. +pub fn estimate_value_size(value: &Value, limits: EventLimits) -> RuntimeResult { + measure_value(value, 0, limits) +} + +fn measure_value(value: &Value, depth: usize, limits: EventLimits) -> RuntimeResult { + if depth > limits.max_depth { + return Err(RuntimeError::new( + RuntimeErrorCode::EventDepthExceeded, + "stream::emit", + "event payload nesting exceeds the configured bound", + ) + .with_limit(limits.max_depth) + .with_value(depth as u64)); + } + + let size = match value { + Value::Null | Value::Bool(_) => 1, + Value::Int(_) | Value::Float(_) => 9, + Value::String(text) => 1usize.saturating_add(text.len()), + Value::Bytes(bytes) => 1usize.saturating_add(bytes.len()), + Value::Callable(_) => 17, + Value::Array(values) => { + let mut size = 5usize; + for child in values.iter() { + size = checked_payload_add(size, measure_value(child, depth + 1, limits)?, limits)?; + } + size + } + Value::Map(entries) => { + let mut size = 5usize; + for (key, child) in entries.iter() { + size = checked_payload_add(size, measure_value(key, depth + 1, limits)?, limits)?; + size = checked_payload_add(size, measure_value(child, depth + 1, limits)?, limits)?; + } + size + } + }; + + if size > limits.max_payload_bytes { + return Err(RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + "stream::emit", + "event payload exceeds the configured byte bound", + ) + .with_limit(limits.max_payload_bytes) + .with_value(size as u64)); + } + Ok(size) +} + +fn checked_payload_add( + current: usize, + additional: usize, + limits: EventLimits, +) -> RuntimeResult { + let total = current.checked_add(additional).ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + "stream::emit", + "event payload size overflowed", + ) + .with_limit(limits.max_payload_bytes) + })?; + if total > limits.max_payload_bytes { + return Err(RuntimeError::new( + RuntimeErrorCode::EventPayloadTooLarge, + "stream::emit", + "event payload exceeds the configured byte bound", + ) + .with_limit(limits.max_payload_bytes) + .with_value(total as u64)); + } + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::{EventLimits, EventPayload}; + use crate::vm::Value; + + #[test] + fn per_item_limits_validate_payload_and_depth() { + let limits = EventLimits::new(32, 4).expect("limits should be valid"); + let payload = + EventPayload::try_new(Value::string("event"), limits).expect("payload should fit"); + assert!(payload.size_bytes() >= 5); + assert_eq!(payload.into_value(), Value::string("event")); + } + + #[test] + fn oversized_or_too_deep_values_are_rejected_before_placement() { + let limits = EventLimits::new(8, 2).expect("limits should be valid"); + let too_large = EventPayload::try_new(Value::string("payload-too-large"), limits) + .expect_err("oversized event should be rejected"); + assert_eq!( + too_large.code(), + super::super::error::RuntimeErrorCode::EventPayloadTooLarge + ); + let too_deep = EventPayload::try_new( + Value::array(vec![Value::array(vec![Value::array(vec![Value::Int(1)])])]), + limits, + ) + .expect_err("too-deep event should be rejected"); + assert_eq!( + too_deep.code(), + super::super::error::RuntimeErrorCode::EventDepthExceeded + ); + } +} diff --git a/src/builtins/runtime/http.rs b/src/builtins/runtime/http.rs new file mode 100644 index 00000000..bdb4b3fb --- /dev/null +++ b/src/builtins/runtime/http.rs @@ -0,0 +1,866 @@ +#[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/io.rs b/src/builtins/runtime/io.rs deleted file mode 100644 index b9ef5340..00000000 --- a/src/builtins/runtime/io.rs +++ /dev/null @@ -1,485 +0,0 @@ -use std::collections::HashMap; -use std::fs::OpenOptions; -use std::future::Future; -use std::io::{Read, Write}; -use std::pin::Pin; -use std::process::{Child, Command, Stdio}; -use std::task::{Context, Poll}; - -use futures_channel::oneshot; -use pd_host_function::pd_host_function; - -use super::HostCallResult; -use crate::vm::{CallReturn, HostOpId, Value, Vm, VmError, VmResult}; - -pub(crate) struct IoState { - pub(super) next_handle: i64, - pub(super) handles: HashMap, - pending_ops: HashMap>, -} - -impl Default for IoState { - fn default() -> Self { - Self { - next_handle: 1, - handles: HashMap::new(), - pending_ops: HashMap::new(), - } - } -} - -pub(super) enum IoHandle { - File(std::fs::File), - PopenRead { child: Child }, - PopenWrite { child: Child }, -} - -struct IoAsyncCompletion { - restored_handle: Option<(i64, IoHandle)>, - result: VmResult, -} - -pub(super) fn cancel_pending_op(vm: &mut Vm, op_id: HostOpId) { - vm.io_state.pending_ops.remove(&op_id); -} - -pub(super) fn poll_builtin_io_op( - vm: &mut Vm, - op_id: HostOpId, - cx: &mut Context<'_>, -) -> Poll> { - let poll_result = { - let receiver = match vm.io_state.pending_ops.get_mut(&op_id) { - Some(receiver) => receiver, - None => { - return Poll::Ready(Err(VmError::HostError(format!( - "unknown builtin io op {op_id}", - )))); - } - }; - Pin::new(receiver).poll(cx) - }; - - match poll_result { - Poll::Pending => Poll::Pending, - Poll::Ready(Ok(completion)) => { - vm.io_state.pending_ops.remove(&op_id); - if let Some((handle_id, handle)) = completion.restored_handle { - vm.io_state.handles.insert(handle_id, handle); - } - Poll::Ready(completion.result) - } - Poll::Ready(Err(_)) => { - vm.io_state.pending_ops.remove(&op_id); - Poll::Ready(Err(VmError::HostError(format!( - "builtin io op {op_id} was cancelled", - )))) - } - } -} - -pub(super) fn close_all_handles(vm: &mut Vm) { - let handles = std::mem::take(&mut vm.io_state.handles); - for (_, handle) in handles { - let _ = close_io_handle(handle); - } -} - -/// Opens a file handle for runtime I/O. -#[pd_host_function(name = "io::open")] -pub(super) fn builtin_io_open( - vm: &mut Vm, - path: &str, - mode: &str, -) -> VmResult> { - let reserved_id = io_reserve_handle_id(vm); - let path = path.to_string(); - let mode = mode.to_string(); - let op_id = schedule_io_task(vm, move || { - let mut options = OpenOptions::new(); - match mode.as_str() { - "r" => { - options.read(true); - } - "w" => { - options.write(true).create(true).truncate(true); - } - "a" => { - options.write(true).create(true).append(true); - } - "r+" => { - options.read(true).write(true); - } - "w+" => { - options.read(true).write(true).create(true).truncate(true); - } - "a+" => { - options.read(true).write(true).create(true).append(true); - } - other => { - return IoAsyncCompletion { - restored_handle: None, - result: Err(VmError::HostError(format!( - "unsupported io_open mode '{other}', expected r/w/a/r+/w+/a+", - ))), - }; - } - } - - match options.open(path) { - Ok(file) => IoAsyncCompletion { - restored_handle: Some((reserved_id, IoHandle::File(file))), - result: Ok(CallReturn::one(Value::Int(reserved_id))), - }, - Err(err) => IoAsyncCompletion { - restored_handle: None, - result: Err(VmError::HostError(format!("io_open failed: {err}"))), - }, - } - })?; - Ok(HostCallResult::Pending(op_id)) -} - -/// Starts a child process and returns a process-backed handle. -#[pd_host_function(name = "io::popen")] -pub(super) fn builtin_io_popen( - vm: &mut Vm, - command: &str, - mode: &str, -) -> VmResult> { - if mode != "r" && mode != "w" { - return Err(VmError::HostError(format!( - "unsupported io_popen mode '{mode}', expected r or w" - ))); - } - let reserved_id = io_reserve_handle_id(vm); - let command = command.to_string(); - let mode = mode.to_string(); - let op_id = schedule_io_task(vm, move || { - let child = match spawn_shell_command(command.as_str(), mode.as_str()) { - Ok(child) => child, - Err(err) => { - return IoAsyncCompletion { - restored_handle: None, - result: Err(err), - }; - } - }; - let handle = match mode.as_str() { - "r" => { - if child.stdout.is_none() { - return IoAsyncCompletion { - restored_handle: None, - result: Err(VmError::HostError( - "io_popen('r') did not provide stdout pipe".to_string(), - )), - }; - } - IoHandle::PopenRead { child } - } - "w" => { - if child.stdin.is_none() { - return IoAsyncCompletion { - restored_handle: None, - result: Err(VmError::HostError( - "io_popen('w') did not provide stdin pipe".to_string(), - )), - }; - } - IoHandle::PopenWrite { child } - } - _ => unreachable!("mode validated above"), - }; - IoAsyncCompletion { - restored_handle: Some((reserved_id, handle)), - result: Ok(CallReturn::one(Value::Int(reserved_id))), - } - })?; - Ok(HostCallResult::Pending(op_id)) -} - -/// Reads all remaining text from an I/O handle. -#[pd_host_function(name = "io::read_all")] -pub(super) fn builtin_io_read_all(vm: &mut Vm, handle_id: i64) -> VmResult> { - let handle = io_take_handle(vm, handle_id)?; - let op_id = schedule_io_task(vm, move || { - let mut handle = handle; - let mut out = String::new(); - let result = match &mut handle { - IoHandle::File(file) => file - .read_to_string(&mut out) - .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}"))) - .map(|_| CallReturn::one(Value::string(out))), - IoHandle::PopenRead { child } => { - let stdout = match child.stdout.as_mut() { - Some(stdout) => stdout, - None => { - return IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result: Err(VmError::HostError( - "io_read_all popen handle missing stdout".to_string(), - )), - }; - } - }; - stdout - .read_to_string(&mut out) - .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}"))) - .map(|_| CallReturn::one(Value::string(out))) - } - IoHandle::PopenWrite { .. } => Err(VmError::HostError( - "io_read_all requires a readable handle".to_string(), - )), - }; - IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result, - } - })?; - Ok(HostCallResult::Pending(op_id)) -} - -/// Reads a single line of text from an I/O handle. -#[pd_host_function(name = "io::read_line")] -pub(super) fn builtin_io_read_line( - vm: &mut Vm, - handle_id: i64, -) -> VmResult> { - let handle = io_take_handle(vm, handle_id)?; - let op_id = schedule_io_task(vm, move || { - let mut handle = handle; - let result = match &mut handle { - IoHandle::File(file) => { - read_line_from_reader(file).map(|line| CallReturn::one(Value::string(line))) - } - IoHandle::PopenRead { child } => { - let stdout = match child.stdout.as_mut() { - Some(stdout) => stdout, - None => { - return IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result: Err(VmError::HostError( - "io_read_line popen handle missing stdout".to_string(), - )), - }; - } - }; - read_line_from_reader(stdout).map(|line| CallReturn::one(Value::string(line))) - } - IoHandle::PopenWrite { .. } => Err(VmError::HostError( - "io_read_line requires a readable handle".to_string(), - )), - }; - IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result, - } - })?; - Ok(HostCallResult::Pending(op_id)) -} - -/// Writes text to an I/O handle. -#[pd_host_function(name = "io::write")] -pub(super) fn builtin_io_write( - vm: &mut Vm, - handle_id: i64, - text: &str, -) -> VmResult> { - let bytes = text.as_bytes().to_vec(); - let handle = io_take_handle(vm, handle_id)?; - let op_id = schedule_io_task(vm, move || { - let mut handle = handle; - let result = match &mut handle { - IoHandle::File(file) => file - .write(&bytes) - .map_err(|err| VmError::HostError(format!("io_write failed: {err}"))) - .map(|written| CallReturn::one(Value::Int(written as i64))), - IoHandle::PopenWrite { child } => { - let stdin = match child.stdin.as_mut() { - Some(stdin) => stdin, - None => { - return IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result: Err(VmError::HostError( - "io_write popen handle missing stdin".to_string(), - )), - }; - } - }; - stdin - .write(&bytes) - .map_err(|err| VmError::HostError(format!("io_write failed: {err}"))) - .map(|written| CallReturn::one(Value::Int(written as i64))) - } - IoHandle::PopenRead { .. } => Err(VmError::HostError( - "io_write requires a writable handle".to_string(), - )), - }; - IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result, - } - })?; - Ok(HostCallResult::Pending(op_id)) -} - -/// Flushes buffered output for an I/O handle. -#[pd_host_function(name = "io::flush")] -pub(super) fn builtin_io_flush(vm: &mut Vm, handle_id: i64) -> VmResult> { - let handle = io_take_handle(vm, handle_id)?; - let op_id = schedule_io_task(vm, move || { - let mut handle = handle; - let result = match &mut handle { - IoHandle::File(file) => file - .flush() - .map_err(|err| VmError::HostError(format!("io_flush failed: {err}"))) - .map(|_| CallReturn::one(Value::Bool(true))), - IoHandle::PopenWrite { child } => { - let stdin = match child.stdin.as_mut() { - Some(stdin) => stdin, - None => { - return IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result: Err(VmError::HostError( - "io_flush popen handle missing stdin".to_string(), - )), - }; - } - }; - stdin - .flush() - .map_err(|err| VmError::HostError(format!("io_flush failed: {err}"))) - .map(|_| CallReturn::one(Value::Bool(true))) - } - IoHandle::PopenRead { .. } => Ok(CallReturn::one(Value::Bool(true))), - }; - IoAsyncCompletion { - restored_handle: Some((handle_id, handle)), - result, - } - })?; - Ok(HostCallResult::Pending(op_id)) -} - -/// Closes an I/O handle. -#[pd_host_function(name = "io::close")] -pub(super) fn builtin_io_close(vm: &mut Vm, handle_id: i64) -> VmResult> { - let handle = io_take_handle(vm, handle_id)?; - let op_id = schedule_io_task(vm, move || IoAsyncCompletion { - restored_handle: None, - result: close_io_handle(handle).map(|_| CallReturn::one(Value::Bool(true))), - })?; - Ok(HostCallResult::Pending(op_id)) -} - -/// Returns whether a file system path exists. -#[pd_host_function(name = "io::exists")] -pub(super) fn builtin_io_exists(vm: &mut Vm, path: &str) -> VmResult> { - let path = path.to_string(); - let op_id = schedule_io_task(vm, move || IoAsyncCompletion { - restored_handle: None, - result: Ok(CallReturn::one(Value::Bool( - std::path::Path::new(path.as_str()).exists(), - ))), - })?; - Ok(HostCallResult::Pending(op_id)) -} - -fn spawn_shell_command(command: &str, mode: &str) -> VmResult { - let mut process = if cfg!(windows) { - let mut cmd = Command::new("cmd"); - cmd.arg("/C").arg(command); - cmd - } else { - let mut cmd = Command::new("sh"); - cmd.arg("-c").arg(command); - cmd - }; - - match mode { - "r" => { - process.stdout(Stdio::piped()).stdin(Stdio::null()); - } - "w" => { - process.stdin(Stdio::piped()).stdout(Stdio::null()); - } - _ => {} - } - - process - .spawn() - .map_err(|err| VmError::HostError(format!("io_popen failed: {err}"))) -} - -fn io_reserve_handle_id(vm: &mut Vm) -> i64 { - let id = vm.io_state.next_handle; - vm.io_state.next_handle = vm.io_state.next_handle.saturating_add(1); - id -} - -fn io_take_handle(vm: &mut Vm, handle_id: i64) -> VmResult { - if handle_id <= 0 { - return Err(VmError::HostError(format!( - "invalid io handle id {handle_id}; expected positive handle id" - ))); - } - vm.io_state - .handles - .remove(&handle_id) - .ok_or_else(|| VmError::HostError(format!("io handle {handle_id} not found"))) -} - -fn schedule_io_task( - vm: &mut Vm, - task: impl FnOnce() -> IoAsyncCompletion + Send + 'static, -) -> VmResult { - let op_id = vm.allocate_host_op_id(); - let (sender, receiver) = oneshot::channel(); - std::thread::Builder::new() - .name("pd-vm-io".to_string()) - .spawn(move || { - let completion = task(); - let _ = sender.send(completion); - }) - .map_err(|err| VmError::HostError(format!("failed to spawn io task: {err}")))?; - vm.io_state.pending_ops.insert(op_id, receiver); - Ok(op_id) -} - -fn close_io_handle(mut handle: IoHandle) -> VmResult<()> { - match &mut handle { - IoHandle::File(file) => { - file.flush().ok(); - } - IoHandle::PopenRead { child } => { - child - .wait() - .map_err(|err| VmError::HostError(format!("io_close popen wait failed: {err}")))?; - } - IoHandle::PopenWrite { child } => { - let _ = child.stdin.take(); - child - .wait() - .map_err(|err| VmError::HostError(format!("io_close popen wait failed: {err}")))?; - } - } - Ok(()) -} - -fn read_line_from_reader(reader: &mut impl Read) -> VmResult { - let mut bytes = Vec::new(); - let mut one = [0u8; 1]; - loop { - let read = reader - .read(&mut one) - .map_err(|err| VmError::HostError(format!("io_read_line failed: {err}")))?; - if read == 0 { - break; - } - bytes.push(one[0]); - if one[0] == b'\n' { - break; - } - } - Ok(String::from_utf8_lossy(&bytes).into_owned()) -} diff --git a/src/builtins/runtime/io/async_io.rs b/src/builtins/runtime/io/async_io.rs new file mode 100644 index 00000000..d38bf818 --- /dev/null +++ b/src/builtins/runtime/io/async_io.rs @@ -0,0 +1,580 @@ +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::Duration; + +#[cfg(unix)] +use std::os::unix::process::CommandExt; + +use pd_host_function::pd_host_function; +use tokio::fs::{File, OpenOptions}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; +use tokio::sync::Mutex; + +use super::super::resource::ResourceTypeId; +use super::super::{ + CancellationReason, CaptureAsyncHostContext, HostFutureOutput, HostOpId, ResourceHandle, + RuntimeError, RuntimeErrorCode, Value, Vm, VmError, VmResult, +}; +use super::{IoPolicy, io_policy}; + +#[derive(Debug)] +pub(crate) enum IoHandle { + File(BufReader), + PopenRead { + child: Child, + stdout: BufReader, + }, + PopenWrite { + child: Child, + stdin: ChildStdin, + }, +} + +struct IoResource { + handle: Mutex>, + process_id: AtomicU32, +} + +impl IoResource { + fn new(handle: IoHandle) -> Self { + let process_id = match &handle { + IoHandle::PopenRead { child, .. } | IoHandle::PopenWrite { child, .. } => { + child.id().unwrap_or(0) + } + IoHandle::File(_) => 0, + }; + Self { + handle: Mutex::new(Some(handle)), + process_id: AtomicU32::new(process_id), + } + } + + async fn take_handle(&self) -> VmResult { + self.handle + .lock() + .await + .take() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string())) + } + + fn close(&self, reason: CancellationReason) -> VmResult<()> { + if let Ok(mut handle) = self.handle.try_lock() + && let Some(handle) = handle.take() + { + start_close_io_handle(handle, reason)?; + } + terminate_process_id(self.process_id.load(Ordering::Acquire), reason)?; + self.process_id.store(0, Ordering::Release); + Ok(()) + } +} + +impl Drop for IoResource { + fn drop(&mut self) { + if let Some(handle) = self.handle.get_mut().take() { + let _ = start_close_io_handle(handle, CancellationReason::VmReset); + } + let _ = terminate_process_id( + self.process_id.load(Ordering::Acquire), + CancellationReason::VmReset, + ); + } +} + +#[derive(Clone)] +pub(crate) struct IoPolicyContext { + policy: Option, +} + +impl CaptureAsyncHostContext for IoPolicyContext { + fn capture(vm: &mut Vm) -> VmResult { + Ok(Self { + policy: io_policy(vm), + }) + } +} + +pub(crate) struct IoHandleContext { + handle: ResourceHandle, + resource: Arc, + max_read_bytes: Option, + max_write_bytes: Option, +} + +impl CaptureAsyncHostContext for IoHandleContext { + fn capture(_vm: &mut Vm) -> VmResult { + Err(VmError::HostError( + "io handle context requires call arguments".to_string(), + )) + } + + fn capture_with_args(vm: &mut Vm, args: &[Value]) -> VmResult { + let handle_id = match args.first() { + Some(Value::Int(value)) => *value, + Some(_) => return Err(VmError::TypeMismatch("int")), + None => return Err(VmError::HostError("missing io handle argument".to_string())), + }; + let handle = resource_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + Ok(Self { + handle, + resource, + max_read_bytes: io_policy(vm).map(|policy| policy.max_read_bytes), + max_write_bytes: io_policy(vm).map(|policy| policy.max_write_bytes), + }) + } +} + +/// Opens a file handle for runtime I/O. +#[pd_host_function(name = "io::open")] +pub(crate) async fn builtin_io_open( + #[pd_host_context] context: IoPolicyContext, + path: String, + mode: String, +) -> VmResult> { + let writes = match mode.as_str() { + "r" => false, + "w" | "a" | "r+" | "w+" | "a+" => true, + other => { + return Err(VmError::HostError(format!( + "io_open unsupported mode '{other}'" + ))); + } + }; + let path = authorize_io_path(context.policy.as_ref(), &path, writes).await?; + let mut options = OpenOptions::new(); + match mode.as_str() { + "r" => { + options.read(true); + } + "w" => { + options.write(true).create(true).truncate(true); + } + "a" => { + options.append(true).create(true); + } + "r+" => { + options.read(true).write(true); + } + "w+" => { + options.read(true).write(true).create(true).truncate(true); + } + "a+" => { + options.read(true).append(true).create(true); + } + _ => unreachable!(), + } + let file = options + .open(path) + .await + .map_err(|error| VmError::HostError(format!("io_open failed: {error}")))?; + let handle = IoHandle::File(BufReader::new(file)); + Ok(HostFutureOutput::complete(move |vm| { + let handle = insert_io_resource(vm, handle)?; + match handle.as_value() { + Value::Int(value) => Ok(value), + _ => unreachable!(), + } + })) +} + +/// Starts a child process and returns a process-backed handle. +#[pd_host_function(name = "io::popen")] +pub(crate) async fn builtin_io_popen( + #[pd_host_context] context: IoPolicyContext, + command: String, + mode: String, +) -> VmResult> { + if mode != "r" && mode != "w" { + return Err(VmError::HostError(format!( + "io_popen unsupported mode '{mode}'" + ))); + } + if !context + .policy + .as_ref() + .is_none_or(|policy| policy.allow_process) + { + return Err(VmError::HostError( + "io_popen requires the command capability".to_string(), + )); + } + let handle = spawn_shell_command(&command, &mode)?; + Ok(HostFutureOutput::complete(move |vm| { + let handle = insert_io_resource(vm, handle)?; + match handle.as_value() { + Value::Int(value) => Ok(value), + _ => unreachable!(), + } + })) +} + +/// Reads all remaining text from an I/O handle. +#[pd_host_function(name = "io::read_all")] +pub(crate) async fn builtin_io_read_all( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let mut guard = context.resource.handle.lock().await; + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + let mut out = String::new(); + match handle { + IoHandle::File(file) => file.read_to_string(&mut out).await, + IoHandle::PopenRead { stdout, .. } => stdout.read_to_string(&mut out).await, + IoHandle::PopenWrite { .. } => { + return Err(VmError::HostError( + "io_read_all cannot read from a write handle".to_string(), + )); + } + } + .map_err(|error| VmError::HostError(format!("io_read_all failed: {error}")))?; + if context + .max_read_bytes + .is_some_and(|limit| out.len() > limit) + { + return Err(VmError::HostError( + "io_read_all exceeded read limit".to_string(), + )); + } + Ok(HostFutureOutput::returning(out)) +} + +/// Reads a single line of text from an I/O handle. +#[pd_host_function(name = "io::read_line")] +pub(crate) async fn builtin_io_read_line( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let mut guard = context.resource.handle.lock().await; + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + let mut line = String::new(); + match handle { + IoHandle::File(file) => file.read_line(&mut line).await, + IoHandle::PopenRead { stdout, .. } => stdout.read_line(&mut line).await, + IoHandle::PopenWrite { .. } => { + return Err(VmError::HostError( + "io_read_line cannot read from a write handle".to_string(), + )); + } + } + .map_err(|error| VmError::HostError(format!("io_read_line failed: {error}")))?; + if context + .max_read_bytes + .is_some_and(|limit| line.len() > limit) + { + return Err(VmError::HostError( + "io_read_line exceeded read limit".to_string(), + )); + } + Ok(HostFutureOutput::returning(line)) +} + +/// Writes text to an I/O handle. +#[pd_host_function(name = "io::write")] +pub(crate) async fn builtin_io_write( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, + text: String, +) -> VmResult> { + if context + .max_write_bytes + .is_some_and(|limit| text.len() > limit) + { + return Err(VmError::HostError( + "io_write exceeded write limit".to_string(), + )); + } + let mut guard = context.resource.handle.lock().await; + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + let written = match handle { + IoHandle::File(file) => file.get_mut().write(text.as_bytes()).await, + IoHandle::PopenWrite { stdin, .. } => stdin.write(text.as_bytes()).await, + IoHandle::PopenRead { .. } => { + return Err(VmError::HostError( + "io_write cannot write to a read handle".to_string(), + )); + } + } + .map_err(|error| VmError::HostError(format!("io_write failed: {error}")))?; + Ok(HostFutureOutput::returning(written as i64)) +} + +/// Flushes buffered output for an I/O handle. +#[pd_host_function(name = "io::flush")] +pub(crate) async fn builtin_io_flush( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let mut guard = context.resource.handle.lock().await; + let handle = guard + .as_mut() + .ok_or_else(|| VmError::HostError("io handle is closed".to_string()))?; + match handle { + IoHandle::File(file) => file.get_mut().flush().await, + IoHandle::PopenWrite { stdin, .. } => stdin.flush().await, + IoHandle::PopenRead { .. } => Ok(()), + } + .map_err(|error| VmError::HostError(format!("io_flush failed: {error}")))?; + Ok(HostFutureOutput::returning(true)) +} + +/// Closes an I/O handle. +#[pd_host_function(name = "io::close")] +pub(crate) async fn builtin_io_close( + #[pd_host_context] context: IoHandleContext, + _handle_id: i64, +) -> VmResult> { + let resource = context.resource; + let handle = context.handle; + let resource_handle = resource.take_handle().await?; + let close_result = close_io_handle(resource_handle, CancellationReason::ResourceClosed).await; + Ok(HostFutureOutput::complete(move |vm| { + super::super::close_runtime_resource(vm, handle, CancellationReason::ResourceClosed) + .map_err(runtime_host_error)?; + close_result?; + Ok(true) + })) +} + +/// Returns whether a file system path exists. +#[pd_host_function(name = "io::exists")] +pub(crate) async fn builtin_io_exists( + #[pd_host_context] context: IoPolicyContext, + path: String, +) -> VmResult> { + let path = authorize_io_path(context.policy.as_ref(), &path, false).await?; + let exists = tokio::fs::try_exists(path) + .await + .map_err(|error| VmError::HostError(format!("io_exists failed: {error}")))?; + Ok(HostFutureOutput::returning(exists)) +} + +#[allow(dead_code)] +pub(crate) fn cancel_builtin_io_op_with_reason( + _vm: &mut Vm, + _op_id: HostOpId, + _reason: CancellationReason, +) { +} + +async fn authorize_io_path( + policy: Option<&IoPolicy>, + path: &str, + writes: bool, +) -> VmResult { + let requested = PathBuf::from(path); + let Some(policy) = policy else { + return Ok(requested); + }; + if writes && !policy.allow_write { + return Err(VmError::HostError( + "io path write requires the write capability".to_string(), + )); + } + let absolute = if requested.is_absolute() { + requested + } else { + std::env::current_dir() + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))? + .join(requested) + }; + let canonical = canonicalize_io_target(&absolute).await?; + for root in &policy.allowed_roots { + let root = tokio::fs::canonicalize(Path::new(root)) + .await + .map_err(|error| { + VmError::HostError(format!( + "io allowed root '{root}' cannot be resolved: {error}" + )) + })?; + if canonical.starts_with(root) { + return Ok(canonical); + } + } + Err(VmError::HostError(format!( + "io path '{}' is outside the allowed roots", + canonical.display() + ))) +} + +async fn canonicalize_io_target(path: &Path) -> VmResult { + if tokio::fs::try_exists(path) + .await + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))? + { + return tokio::fs::canonicalize(path) + .await + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}"))); + } + let parent = path + .parent() + .ok_or_else(|| VmError::HostError(format!("io path '{}' has no parent", path.display())))?; + let file_name = path.file_name().ok_or_else(|| { + VmError::HostError(format!("io path '{}' has no file name", path.display())) + })?; + tokio::fs::canonicalize(parent) + .await + .map(|parent| parent.join(file_name)) + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}"))) +} + +fn spawn_shell_command(command: &str, mode: &str) -> VmResult { + let mut process = if cfg!(windows) { + let mut cmd = Command::new("cmd"); + cmd.arg("/C").arg(command); + cmd + } else { + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(command); + cmd + }; + #[cfg(unix)] + process.as_std_mut().process_group(0); + process.kill_on_drop(true); + match mode { + "r" => { + process.stdout(Stdio::piped()).stdin(Stdio::null()); + } + "w" => { + process.stdin(Stdio::piped()).stdout(Stdio::null()); + } + _ => {} + } + let mut child = process + .spawn() + .map_err(|error| VmError::HostError(format!("io_popen failed: {error}")))?; + match mode { + "r" => { + let stdout = child.stdout.take().ok_or_else(|| { + VmError::HostError("io_popen failed to capture stdout".to_string()) + })?; + Ok(IoHandle::PopenRead { + child, + stdout: BufReader::new(stdout), + }) + } + "w" => { + let stdin = child.stdin.take().ok_or_else(|| { + VmError::HostError("io_popen failed to capture stdin".to_string()) + })?; + Ok(IoHandle::PopenWrite { child, stdin }) + } + _ => unreachable!(), + } +} + +fn resource_handle(handle_id: i64) -> VmResult { + if handle_id <= 0 { + return Err(VmError::HostError(format!( + "invalid io handle id {handle_id}; expected positive handle id" + ))); + } + ResourceHandle::from_value(&Value::Int(handle_id)).map_err(runtime_host_error) +} + +fn io_resource_for_handle(vm: &Vm, handle: ResourceHandle) -> VmResult> { + vm.host + .runtime_resources + .get::>(handle, ResourceTypeId::IO_FILE) + .cloned() + .map_err(runtime_host_error) +} + +fn insert_io_resource(vm: &mut Vm, handle: IoHandle) -> VmResult { + vm.host + .runtime_resources + .insert_with_cleanup( + ResourceTypeId::IO_FILE, + Arc::new(IoResource::new(handle)), + |resource, reason| resource.close(reason).map_err(io_cleanup_error), + ) + .map_err(runtime_host_error) +} + +fn runtime_host_error(error: impl std::fmt::Display) -> VmError { + VmError::HostError(error.to_string()) +} + +fn io_cleanup_error(error: VmError) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::ResourceCleanupFailed, + "io::close", + error.to_string(), + ) +} + +async fn close_io_handle(mut handle: IoHandle, reason: CancellationReason) -> VmResult<()> { + match &mut handle { + IoHandle::File(file) => { + file.get_mut() + .flush() + .await + .map_err(|error| VmError::HostError(format!("io close failed: {error}")))?; + } + IoHandle::PopenRead { child, .. } => wait_for_child(child, reason).await?, + IoHandle::PopenWrite { child, stdin } => { + stdin + .shutdown() + .await + .map_err(|error| VmError::HostError(format!("io close failed: {error}")))?; + wait_for_child(child, reason).await?; + } + } + Ok(()) +} + +async fn wait_for_child(child: &mut Child, reason: CancellationReason) -> VmResult<()> { + if !matches!(reason, CancellationReason::ResourceClosed) { + let _ = child.start_kill(); + } + match tokio::time::timeout(Duration::from_secs(1), child.wait()).await { + Ok(Ok(_)) => Ok(()), + Ok(Err(error)) => Err(VmError::HostError(format!( + "io process cleanup failed: {error}" + ))), + Err(_) => { + let _ = child.start_kill(); + child + .wait() + .await + .map(|_| ()) + .map_err(|error| VmError::HostError(format!("io process cleanup failed: {error}"))) + } + } +} + +fn start_close_io_handle(mut handle: IoHandle, _reason: CancellationReason) -> VmResult<()> { + match &mut handle { + IoHandle::File(_) => {} + IoHandle::PopenRead { child, .. } | IoHandle::PopenWrite { child, .. } => { + child.start_kill().map_err(|error| { + VmError::HostError(format!("io process cleanup failed: {error}")) + })?; + } + } + Ok(()) +} + +fn terminate_process_id(process_id: u32, reason: CancellationReason) -> VmResult<()> { + if process_id == 0 || matches!(reason, CancellationReason::ResourceClosed) { + return Ok(()); + } + #[cfg(unix)] + unsafe { + libc::kill(-(process_id as i32), libc::SIGKILL); + } + #[cfg(windows)] + { + let _ = process_id; + } + Ok(()) +} diff --git a/src/builtins/runtime/io/blocking.rs b/src/builtins/runtime/io/blocking.rs new file mode 100644 index 00000000..6c3bb0bf --- /dev/null +++ b/src/builtins/runtime/io/blocking.rs @@ -0,0 +1,1001 @@ +use std::fs::OpenOptions; +use std::future::Future; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, TryLockError}; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +#[cfg(unix)] +use std::os::unix::process::CommandExt; + +use futures_channel::oneshot; +use pd_host_function::pd_host_function; + +use super::super::HostCallResult; +use super::super::cancellation::{CancellationReason, OperationId, OperationOwner}; +use super::super::error::{RuntimeError, RuntimeErrorCode}; +use super::super::resource::{ResourceHandle, ResourceTypeId}; +use crate::vm::{CallReturn, HostOpId, Value, Vm, VmError, VmResult}; + +pub(crate) enum IoHandle { + File(std::fs::File), + PopenRead { child: Child }, + PopenWrite { child: Child }, +} + +struct IoResource { + handle: Mutex>, + process_id: AtomicU32, +} + +impl IoResource { + fn new(handle: IoHandle) -> Self { + let process_id = match &handle { + IoHandle::PopenRead { child } | IoHandle::PopenWrite { child } => Some(child.id()), + IoHandle::File(_) => None, + }; + Self { + handle: Mutex::new(Some(handle)), + process_id: AtomicU32::new(process_id.unwrap_or(0)), + } + } + + fn with_handle_mut(&self, apply: impl FnOnce(&mut IoHandle) -> VmResult) -> VmResult { + let mut handle = self + .handle + .lock() + .map_err(|_| VmError::HostError("io resource lock was poisoned".to_string()))?; + let handle = handle + .as_mut() + .ok_or_else(|| VmError::HostError("io resource is already closing".to_string()))?; + apply(handle) + } + + fn take_handle(&self) -> VmResult { + self.handle + .lock() + .map_err(|_| VmError::HostError("io resource lock was poisoned".to_string()))? + .take() + .ok_or_else(|| VmError::HostError("io resource is already closing".to_string())) + } + + fn close(&self, reason: CancellationReason) -> VmResult<()> { + let process_id = self.process_id.swap(0, Ordering::AcqRel); + let termination_error = if reason != CancellationReason::ResourceClosed && process_id != 0 { + terminate_process_tree(process_id).err() + } else { + None + }; + + let deadline = Instant::now() + Duration::from_millis(500); + loop { + match self.handle.try_lock() { + Ok(mut handle) => { + let close_result = match handle.take() { + Some(handle) => close_io_handle(handle, reason), + None => Ok(()), + }; + return match close_result { + Err(error) => Err(error), + Ok(()) => termination_error.map_or(Ok(()), Err), + }; + } + Err(TryLockError::Poisoned(_)) => { + return Err(VmError::HostError( + "io resource lock was poisoned".to_string(), + )); + } + Err(TryLockError::WouldBlock) if Instant::now() >= deadline => { + let termination_detail = termination_error + .as_ref() + .map(|error| format!("; process termination failed: {error}")) + .unwrap_or_default(); + return Err(VmError::HostError(format!( + "timed out interrupting pending io operation{termination_detail}" + ))); + } + Err(TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(5)), + } + } + } +} + +impl Drop for IoResource { + fn drop(&mut self) { + let _ = self.close(CancellationReason::VmReset); + } +} + +struct IoAsyncCompletion { + opened_handle: Option, + closed_handle: Option, + result: VmResult, +} + +impl IoAsyncCompletion { + fn result(result: VmResult) -> Self { + Self { + opened_handle: None, + closed_handle: None, + result, + } + } +} + +impl Drop for IoAsyncCompletion { + fn drop(&mut self) { + let Some(handle) = self.opened_handle.take() else { + return; + }; + let _ = IoResource::new(handle).close(CancellationReason::VmReset); + } +} + +pub(crate) fn poll_builtin_io_op( + vm: &mut Vm, + op_id: HostOpId, + cx: &mut Context<'_>, +) -> Poll> { + let operation_id = match OperationId::from_raw(op_id) { + Ok(operation_id) => operation_id, + Err(error) => return Poll::Ready(Err(runtime_host_error(error))), + }; + let operation = match vm.host.runtime_operations.get(operation_id) { + Ok(operation) => operation, + Err(error) => return Poll::Ready(Err(runtime_host_error(error))), + }; + let Some(callback) = operation.payload() else { + return Poll::Ready(Err(VmError::HostError(format!( + "builtin io op {op_id} has no completion payload", + )))); + }; + let poll_result = { + let receiver = match vm + .host + .runtime_resources + .get_mut::>(callback, ResourceTypeId::CALLBACK) + { + Ok(receiver) => receiver, + Err(error) => return Poll::Ready(Err(runtime_host_error(error))), + }; + Pin::new(receiver).poll(cx) + }; + + match poll_result { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(mut completion)) => { + let _ = super::super::close_runtime_resource( + vm, + callback, + CancellationReason::ResourceClosed, + ); + + if let Some(closed_handle) = completion.closed_handle + && let Err(error) = super::super::close_runtime_resource( + vm, + closed_handle, + CancellationReason::ResourceClosed, + ) + { + completion.result = Err(runtime_host_error(error)); + } + if let Some(opened_handle) = completion.opened_handle.take() { + let result = insert_io_resource(vm, opened_handle) + .map(|handle| CallReturn::one(handle.as_value())); + completion.result = result; + } + Poll::Ready(std::mem::replace( + &mut completion.result, + Ok(CallReturn::none()), + )) + } + Poll::Ready(Err(_)) => { + let _ = + super::super::close_runtime_resource(vm, callback, CancellationReason::Requested); + Poll::Ready(Err(VmError::HostError(format!( + "builtin io op {op_id} was cancelled", + )))) + } + } +} + +/// Opens a file handle for runtime I/O. +#[pd_host_function(name = "io::open")] +pub(crate) fn builtin_io_open( + vm: &mut Vm, + path: &str, + mode: &str, +) -> VmResult> { + let writes = match mode { + "r" => false, + "w" | "a" | "r+" | "w+" | "a+" => true, + other => { + return Err(VmError::HostError(format!( + "unsupported io_open mode '{other}', expected r/w/a/r+/w+/a+" + ))); + } + }; + let path = authorize_io_path(vm, path, writes)?; + let mode = mode.to_string(); + let op_id = schedule_io_task(vm, None, move || { + let mut options = OpenOptions::new(); + match mode.as_str() { + "r" => { + options.read(true); + } + "w" => { + options.write(true).create(true).truncate(true); + } + "a" => { + options.write(true).create(true).append(true); + } + "r+" => { + options.read(true).write(true); + } + "w+" => { + options.read(true).write(true).create(true).truncate(true); + } + "a+" => { + options.read(true).write(true).create(true).append(true); + } + other => { + return IoAsyncCompletion::result(Err(VmError::HostError(format!( + "unsupported io_open mode '{other}', expected r/w/a/r+/w+/a+", + )))); + } + } + + match options.open(path) { + Ok(file) => IoAsyncCompletion { + opened_handle: Some(IoHandle::File(file)), + closed_handle: None, + result: Ok(CallReturn::none()), + }, + Err(err) => { + IoAsyncCompletion::result(Err(VmError::HostError(format!("io_open failed: {err}")))) + } + } + })?; + Ok(HostCallResult::Pending(op_id)) +} + +/// Starts a child process and returns a process-backed handle. +#[pd_host_function(name = "io::popen")] +pub(crate) fn builtin_io_popen( + vm: &mut Vm, + command: &str, + mode: &str, +) -> VmResult> { + if mode != "r" && mode != "w" { + return Err(VmError::HostError(format!( + "unsupported io_popen mode '{mode}', expected r or w" + ))); + } + if super::io_policy(vm).is_some_and(|policy| !policy.allow_process) { + return Err(VmError::HostError( + "io_popen requires the process capability".to_string(), + )); + } + let command = command.to_string(); + let mode = mode.to_string(); + let op_id = schedule_io_task(vm, None, move || { + let child = match spawn_shell_command(command.as_str(), mode.as_str()) { + Ok(child) => child, + Err(err) => return IoAsyncCompletion::result(Err(err)), + }; + let handle = match mode.as_str() { + "r" => { + if child.stdout.is_none() { + return IoAsyncCompletion::result(Err(VmError::HostError( + "io_popen('r') did not provide stdout pipe".to_string(), + ))); + } + IoHandle::PopenRead { child } + } + "w" => { + if child.stdin.is_none() { + return IoAsyncCompletion::result(Err(VmError::HostError( + "io_popen('w') did not provide stdin pipe".to_string(), + ))); + } + IoHandle::PopenWrite { child } + } + _ => unreachable!("mode validated above"), + }; + IoAsyncCompletion { + opened_handle: Some(handle), + closed_handle: None, + result: Ok(CallReturn::none()), + } + })?; + Ok(HostCallResult::Pending(op_id)) +} + +/// Reads all remaining text from an I/O handle. +#[pd_host_function(name = "io::read_all")] +pub(crate) fn builtin_io_read_all(vm: &mut Vm, handle_id: i64) -> VmResult> { + let max_read_bytes = super::io_policy(vm).map(|policy| policy.max_read_bytes); + let handle = resource_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + let op_id = schedule_io_task(vm, Some(handle), move || { + let result = resource.with_handle_mut(|handle| { + let mut out = String::new(); + match handle { + IoHandle::File(file) => { + read_to_string_with_limit(file, max_read_bytes, &mut out)?; + } + IoHandle::PopenRead { child } => { + read_to_string_with_limit( + child.stdout.as_mut().ok_or_else(|| { + VmError::HostError( + "io_read_all popen handle missing stdout".to_string(), + ) + })?, + max_read_bytes, + &mut out, + )?; + } + IoHandle::PopenWrite { .. } => { + return Err(VmError::HostError( + "io_read_all requires a readable handle".to_string(), + )); + } + }; + Ok(CallReturn::one(Value::string(out))) + }); + IoAsyncCompletion::result(result) + })?; + Ok(HostCallResult::Pending(op_id)) +} + +/// Reads a single line of text from an I/O handle. +#[pd_host_function(name = "io::read_line")] +pub(crate) fn builtin_io_read_line( + vm: &mut Vm, + handle_id: i64, +) -> VmResult> { + let max_read_bytes = super::io_policy(vm).map(|policy| policy.max_read_bytes); + let handle = resource_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + let op_id = schedule_io_task(vm, Some(handle), move || { + let result = resource.with_handle_mut(|handle| { + let line = match handle { + IoHandle::File(file) => read_line_from_reader(file, max_read_bytes)?, + IoHandle::PopenRead { child } => read_line_from_reader( + child.stdout.as_mut().ok_or_else(|| { + VmError::HostError("io_read_line popen handle missing stdout".to_string()) + })?, + max_read_bytes, + )?, + IoHandle::PopenWrite { .. } => { + return Err(VmError::HostError( + "io_read_line requires a readable handle".to_string(), + )); + } + }; + Ok(CallReturn::one(Value::string(line))) + }); + IoAsyncCompletion::result(result) + })?; + Ok(HostCallResult::Pending(op_id)) +} + +/// Writes text to an I/O handle. +#[pd_host_function(name = "io::write")] +pub(crate) fn builtin_io_write( + vm: &mut Vm, + handle_id: i64, + text: &str, +) -> VmResult> { + if let Some(policy) = super::io_policy(vm) + && text.len() > policy.max_write_bytes + { + return Err(VmError::HostError(format!( + "io_write exceeds the configured write limit of {} bytes", + policy.max_write_bytes + ))); + } + let bytes = text.as_bytes().to_vec(); + let handle = resource_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + let op_id = schedule_io_task(vm, Some(handle), move || { + let result = resource.with_handle_mut(|handle| { + let written = match handle { + IoHandle::File(file) => file + .write(&bytes) + .map_err(|err| VmError::HostError(format!("io_write failed: {err}")))?, + IoHandle::PopenWrite { child } => child + .stdin + .as_mut() + .ok_or_else(|| { + VmError::HostError("io_write popen handle missing stdin".to_string()) + })? + .write(&bytes) + .map_err(|err| VmError::HostError(format!("io_write failed: {err}")))?, + IoHandle::PopenRead { .. } => { + return Err(VmError::HostError( + "io_write requires a writable handle".to_string(), + )); + } + }; + Ok(CallReturn::one(Value::Int(written as i64))) + }); + IoAsyncCompletion::result(result) + })?; + Ok(HostCallResult::Pending(op_id)) +} + +/// Flushes buffered output for an I/O handle. +#[pd_host_function(name = "io::flush")] +pub(crate) fn builtin_io_flush(vm: &mut Vm, handle_id: i64) -> VmResult> { + let handle = resource_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + let op_id = schedule_io_task(vm, Some(handle), move || { + let result = resource.with_handle_mut(|handle| { + match handle { + IoHandle::File(file) => file + .flush() + .map_err(|err| VmError::HostError(format!("io_flush failed: {err}")))?, + IoHandle::PopenWrite { child } => child + .stdin + .as_mut() + .ok_or_else(|| { + VmError::HostError("io_flush popen handle missing stdin".to_string()) + })? + .flush() + .map_err(|err| VmError::HostError(format!("io_flush failed: {err}")))?, + IoHandle::PopenRead { .. } => {} + } + Ok(CallReturn::one(Value::Bool(true))) + }); + IoAsyncCompletion::result(result) + })?; + Ok(HostCallResult::Pending(op_id)) +} + +/// Closes an I/O handle. +#[pd_host_function(name = "io::close")] +pub(crate) fn builtin_io_close(vm: &mut Vm, handle_id: i64) -> VmResult> { + let handle = resource_handle(handle_id)?; + let resource = io_resource_for_handle(vm, handle)?; + let op_id = schedule_io_task(vm, Some(handle), move || { + let result = resource + .take_handle() + .and_then(|handle| close_io_handle(handle, CancellationReason::ResourceClosed)) + .map(|_| CallReturn::one(Value::Bool(true))); + IoAsyncCompletion { + opened_handle: None, + closed_handle: Some(handle), + result, + } + })?; + Ok(HostCallResult::Pending(op_id)) +} + +/// Returns whether a file system path exists. +#[pd_host_function(name = "io::exists")] +pub(crate) fn builtin_io_exists(vm: &mut Vm, path: &str) -> VmResult> { + let path = authorize_io_path(vm, path, false)?; + let op_id = schedule_io_task(vm, None, move || { + IoAsyncCompletion::result(Ok(CallReturn::one(Value::Bool(path.exists())))) + })?; + Ok(HostCallResult::Pending(op_id)) +} + +fn authorize_io_path(vm: &Vm, path: &str, writes: bool) -> VmResult { + let requested = PathBuf::from(path); + let Some(policy) = super::io_policy(vm) else { + return Ok(requested); + }; + if writes && !policy.allow_write { + return Err(VmError::HostError( + "io path write requires the write capability".to_string(), + )); + } + let absolute = if requested.is_absolute() { + requested + } else { + std::env::current_dir() + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}")))? + .join(requested) + }; + let canonical = canonicalize_io_target(&absolute)?; + for root in &policy.allowed_roots { + let root = Path::new(root).canonicalize().map_err(|error| { + VmError::HostError(format!( + "io allowed root '{root}' cannot be resolved: {error}" + )) + })?; + if canonical.starts_with(root) { + return Ok(canonical); + } + } + Err(VmError::HostError(format!( + "io path '{}' is outside the allowed roots", + canonical.display() + ))) +} + +fn canonicalize_io_target(path: &Path) -> VmResult { + if path.exists() { + return path + .canonicalize() + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}"))); + } + let parent = path + .parent() + .ok_or_else(|| VmError::HostError(format!("io path '{}' has no parent", path.display())))?; + let file_name = path.file_name().ok_or_else(|| { + VmError::HostError(format!("io path '{}' has no file name", path.display())) + })?; + parent + .canonicalize() + .map(|parent| parent.join(file_name)) + .map_err(|error| VmError::HostError(format!("io path resolution failed: {error}"))) +} + +fn spawn_shell_command(command: &str, mode: &str) -> VmResult { + let mut process = if cfg!(windows) { + let mut cmd = Command::new("cmd"); + cmd.arg("/C").arg(command); + cmd + } else { + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(command); + cmd + }; + + #[cfg(unix)] + process.process_group(0); + + match mode { + "r" => { + process.stdout(Stdio::piped()).stdin(Stdio::null()); + } + "w" => { + process.stdin(Stdio::piped()).stdout(Stdio::null()); + } + _ => {} + } + + process + .spawn() + .map_err(|err| VmError::HostError(format!("io_popen failed: {err}"))) +} + +fn resource_handle(handle_id: i64) -> VmResult { + if handle_id <= 0 { + return Err(VmError::HostError(format!( + "invalid io handle id {handle_id}; expected positive handle id" + ))); + } + ResourceHandle::from_value(&Value::Int(handle_id)).map_err(runtime_host_error) +} + +fn io_resource_for_handle(vm: &Vm, handle: ResourceHandle) -> VmResult> { + vm.host + .runtime_resources + .get::>(handle, ResourceTypeId::IO_FILE) + .cloned() + .map_err(runtime_host_error) +} + +fn insert_io_resource(vm: &mut Vm, handle: IoHandle) -> VmResult { + vm.host + .runtime_resources + .insert_with_cleanup( + ResourceTypeId::IO_FILE, + Arc::new(IoResource::new(handle)), + |resource, reason| resource.close(reason).map_err(io_cleanup_error), + ) + .map_err(runtime_host_error) +} + +fn schedule_io_task( + vm: &mut Vm, + target_resource: Option, + task: impl FnOnce() -> IoAsyncCompletion + Send + 'static, +) -> VmResult { + let operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Io, + Some(&vm.run_ctx.cancellation), + None, + None, + ) + .map_err(runtime_host_error)?; + if let Some(target_resource) = target_resource { + operation.set_resource(target_resource); + } + let op_id = operation.id().raw(); + let worker_operation = operation.clone(); + let worker_token = operation.token(); + let (sender, receiver) = oneshot::channel(); + let callback = match vm + .host + .runtime_resources + .insert(ResourceTypeId::CALLBACK, receiver) + { + Ok(callback) => callback, + Err(error) => { + let _ = vm + .host + .runtime_operations + .cancel(operation.id(), CancellationReason::Requested); + return Err(runtime_host_error(error)); + } + }; + operation.set_payload(callback); + + let completion = if let Some(reason) = worker_token.reason() { + IoAsyncCompletion::result(Err(VmError::HostError(format!( + "io operation cancelled: {reason:?}" + )))) + } else { + task() + }; + match &completion.result { + Ok(_) => { + let _ = worker_operation.complete(); + } + Err(error) => { + let _ = worker_operation.fail( + RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "io::operation", + error.to_string(), + ) + .with_value(op_id), + ); + } + } + let _ = sender.send(completion); + + Ok(op_id) +} + +fn runtime_host_error(error: impl std::fmt::Display) -> VmError { + VmError::HostError(error.to_string()) +} + +fn io_cleanup_error(error: VmError) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::ResourceCleanupFailed, + "io::close", + error.to_string(), + ) +} + +fn close_io_handle(mut handle: IoHandle, reason: CancellationReason) -> VmResult<()> { + match &mut handle { + IoHandle::File(file) => { + file.flush().ok(); + } + IoHandle::PopenRead { child } => wait_for_child(child, reason)?, + IoHandle::PopenWrite { child } => { + let _ = child.stdin.take(); + wait_for_child(child, reason)?; + } + } + Ok(()) +} + +fn wait_for_child(child: &mut Child, reason: CancellationReason) -> VmResult<()> { + if reason == CancellationReason::ResourceClosed { + child + .wait() + .map_err(|err| VmError::HostError(format!("io_close popen wait failed: {err}")))?; + return Ok(()); + } + + let deadline = Instant::now() + Duration::from_millis(500); + loop { + match child.try_wait() { + Ok(Some(_)) => return Ok(()), + Ok(None) if Instant::now() >= deadline => { + if let Err(kill_error) = child.kill() { + return match child.try_wait() { + Ok(Some(_)) => Ok(()), + Ok(None) => Err(VmError::HostError(format!( + "timed out waiting for cancelled io process; direct child fallback failed: {kill_error}" + ))), + Err(wait_error) => Err(VmError::HostError(format!( + "direct child fallback failed: {kill_error}; child status check failed: {wait_error}" + ))), + }; + } + child.wait().map_err(|error| { + VmError::HostError(format!( + "io_close popen wait after direct child fallback failed: {error}" + )) + })?; + return Ok(()); + } + Ok(None) => std::thread::sleep(Duration::from_millis(5)), + Err(error) => { + return Err(VmError::HostError(format!( + "io_close popen wait failed: {error}" + ))); + } + } + } +} + +#[cfg(unix)] +fn terminate_process_tree(process_id: u32) -> VmResult<()> { + let process_id = libc::pid_t::try_from(process_id).map_err(|_| { + VmError::HostError(format!( + "io_close popen process id {process_id} exceeds the platform pid range" + )) + })?; + let group_result = signal_unix_process(-process_id); + match group_result { + Ok(()) => Ok(()), + Err(error) if error.raw_os_error() == Some(libc::ESRCH) => Ok(()), + Err(group_error) => { + let fallback_result = signal_unix_process(process_id); + let fallback_detail = match fallback_result { + Ok(()) => "direct process fallback succeeded".to_string(), + Err(error) if error.raw_os_error() == Some(libc::ESRCH) => { + "direct process had already exited".to_string() + } + Err(error) => format!("direct process fallback failed: {error}"), + }; + Err(VmError::HostError(format!( + "io_close popen process-group termination failed: {group_error}; {fallback_detail}" + ))) + } + } +} + +#[cfg(unix)] +fn signal_unix_process(process_id: libc::pid_t) -> std::io::Result<()> { + // SAFETY: process_id is either the tracked child pid or its negative process-group id. + if unsafe { libc::kill(process_id, libc::SIGKILL) } == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } +} + +#[cfg(windows)] +fn terminate_process_tree(process_id: u32) -> VmResult<()> { + windows_process_tree::terminate(process_id) +} + +#[cfg(windows)] +mod windows_process_tree { + use std::collections::{HashMap, HashSet}; + use std::ffi::c_void; + use std::io; + use std::mem; + use std::ptr; + + use super::{VmError, VmResult}; + + type Handle = *mut c_void; + + const INVALID_HANDLE_VALUE: Handle = -1_isize as Handle; + const TH32CS_SNAPPROCESS: u32 = 0x0000_0002; + const PROCESS_TERMINATE: u32 = 0x0001; + const ERROR_NO_MORE_FILES: i32 = 18; + const ERROR_INVALID_PARAMETER: i32 = 87; + + #[repr(C)] + struct ProcessEntry32W { + size: u32, + usage_count: u32, + process_id: u32, + default_heap_id: usize, + module_id: u32, + thread_count: u32, + parent_process_id: u32, + base_priority: i32, + flags: u32, + executable: [u16; 260], + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn CreateToolhelp32Snapshot(flags: u32, process_id: u32) -> Handle; + fn Process32FirstW(snapshot: Handle, entry: *mut ProcessEntry32W) -> i32; + fn Process32NextW(snapshot: Handle, entry: *mut ProcessEntry32W) -> i32; + fn OpenProcess(access: u32, inherit_handle: i32, process_id: u32) -> Handle; + fn TerminateProcess(process: Handle, exit_code: u32) -> i32; + fn CloseHandle(handle: Handle) -> i32; + } + + pub(crate) fn terminate(root_process_id: u32) -> VmResult<()> { + let descendants = match descendant_processes(root_process_id) { + Ok(descendants) => descendants, + Err(snapshot_error) => { + let fallback_detail = match terminate_process(root_process_id) { + Ok(()) => "direct process fallback succeeded".to_string(), + Err(error) => format!("direct process fallback failed: {error}"), + }; + return Err(VmError::HostError(format!( + "io_close popen Windows process-tree snapshot failed: {snapshot_error}; {fallback_detail}" + ))); + } + }; + let mut first_error = None; + for process_id in descendants.into_iter().rev() { + if let Err(error) = terminate_process(process_id) { + first_error.get_or_insert(error); + } + } + if let Err(error) = terminate_process(root_process_id) { + first_error.get_or_insert(error); + } + + match first_error { + Some(error) => Err(VmError::HostError(format!( + "io_close popen Windows process-tree termination failed: {error}" + ))), + None => Ok(()), + } + } + + fn descendant_processes(root_process_id: u32) -> VmResult> { + let entries = snapshot_processes().map_err(|error| { + VmError::HostError(format!( + "io_close popen Windows process snapshot failed: {error}" + )) + })?; + let mut children_by_parent = HashMap::>::new(); + for (process_id, parent_process_id) in entries { + children_by_parent + .entry(parent_process_id) + .or_default() + .push(process_id); + } + + let mut descendants = Vec::new(); + let mut visited = HashSet::new(); + let mut pending = vec![root_process_id]; + visited.insert(root_process_id); + while let Some(parent_process_id) = pending.pop() { + let Some(children) = children_by_parent.get(&parent_process_id) else { + continue; + }; + for &child_process_id in children { + if visited.insert(child_process_id) { + descendants.push(child_process_id); + pending.push(child_process_id); + } + } + } + Ok(descendants) + } + + fn snapshot_processes() -> io::Result> { + // SAFETY: the snapshot API receives fixed constants and initialized storage of the + // documented PROCESSENTRY32W layout. Every acquired handle is closed below. + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + + let mut entry: ProcessEntry32W = mem::zeroed(); + entry.size = mem::size_of::() as u32; + let mut entries = Vec::new(); + if Process32FirstW(snapshot, &mut entry) == 0 { + let error = io::Error::last_os_error(); + let _ = CloseHandle(snapshot); + if error.raw_os_error() == Some(ERROR_NO_MORE_FILES) { + return Ok(entries); + } + return Err(error); + } + + loop { + entries.push((entry.process_id, entry.parent_process_id)); + entry = mem::zeroed(); + entry.size = mem::size_of::() as u32; + if Process32NextW(snapshot, &mut entry) == 0 { + let error = io::Error::last_os_error(); + let close_result = CloseHandle(snapshot); + if error.raw_os_error() != Some(ERROR_NO_MORE_FILES) { + return Err(error); + } + if close_result == 0 { + return Err(io::Error::last_os_error()); + } + return Ok(entries); + } + } + } + } + + fn terminate_process(process_id: u32) -> io::Result<()> { + // SAFETY: OpenProcess returns an owned kernel handle which is closed on every path. + unsafe { + let process = OpenProcess(PROCESS_TERMINATE, 0, process_id); + if process == ptr::null_mut() { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER) { + return Ok(()); + } + return Err(error); + } + let terminate_result = TerminateProcess(process, 1); + let terminate_error = (terminate_result == 0).then(io::Error::last_os_error); + let close_result = CloseHandle(process); + if let Some(error) = terminate_error { + return Err(error); + } + if close_result == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + } +} + +#[cfg(not(any(unix, windows)))] +fn terminate_process_tree(process_id: u32) -> VmResult<()> { + Err(VmError::HostError(format!( + "io_close popen process-tree termination is unsupported for process {process_id}" + ))) +} + +fn read_to_string_with_limit( + reader: &mut impl Read, + max_read_bytes: Option, + out: &mut String, +) -> VmResult<()> { + match max_read_bytes { + None => { + reader + .read_to_string(out) + .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?; + } + Some(limit) => { + let take_limit = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1); + reader + .take(take_limit) + .read_to_string(out) + .map_err(|err| VmError::HostError(format!("io_read_all failed: {err}")))?; + if out.len() > limit { + return Err(VmError::HostError(format!( + "io_read_all exceeds the configured read limit of {limit} bytes" + ))); + } + } + } + Ok(()) +} + +fn read_line_from_reader( + reader: &mut impl Read, + max_read_bytes: Option, +) -> VmResult { + let mut bytes = Vec::new(); + let mut one = [0u8; 1]; + loop { + let read = reader + .read(&mut one) + .map_err(|err| VmError::HostError(format!("io_read_line failed: {err}")))?; + if read == 0 { + break; + } + bytes.push(one[0]); + if max_read_bytes.is_some_and(|limit| bytes.len() > limit) { + return Err(VmError::HostError(format!( + "io_read_line exceeds the configured read limit of {} bytes", + max_read_bytes.expect("read limit should be present") + ))); + } + if one[0] == b'\n' { + break; + } + } + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} diff --git a/src/builtins/runtime/io/mod.rs b/src/builtins/runtime/io/mod.rs new file mode 100644 index 00000000..4dd722c1 --- /dev/null +++ b/src/builtins/runtime/io/mod.rs @@ -0,0 +1,66 @@ +use super::borrow_arg; +#[cfg(feature = "async")] +use super::{CallOutcome, CaptureAsyncHostContext, return_one}; +use crate::vm::Vm; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IoPolicy { + pub allowed_roots: Vec, + pub allow_write: bool, + pub allow_process: bool, + pub max_read_bytes: usize, + pub max_write_bytes: usize, +} + +impl Default for IoPolicy { + fn default() -> Self { + Self { + allowed_roots: Vec::new(), + allow_write: false, + allow_process: false, + max_read_bytes: 1024 * 1024, + max_write_bytes: 1024 * 1024, + } + } +} + +struct IoHostState { + policy: IoPolicy, +} + +/// I/O host configuration owned by the I/O host implementation. +pub trait IoHostExt { + fn configure_io(&mut self, policy: IoPolicy); + fn clear_io_configuration(&mut self); +} + +impl IoHostExt for Vm { + fn configure_io(&mut self, mut policy: IoPolicy) { + policy.allowed_roots.sort(); + policy.allowed_roots.dedup(); + self.host.set_host_function_state(IoHostState { policy }); + } + + fn clear_io_configuration(&mut self) { + self.host.remove_host_function_state::(); + } +} + +pub(super) fn io_policy(vm: &Vm) -> Option { + vm.host + .host_function_state::() + .map(|state| state.policy.clone()) + .or_else(|| (!vm.host.default_builtin_capabilities_enabled()).then(IoPolicy::default)) +} + +#[cfg(all(feature = "async", not(target_arch = "wasm32")))] +mod async_io; +#[cfg(all(not(feature = "async"), not(target_arch = "wasm32")))] +mod blocking; + +#[cfg(target_arch = "wasm32")] +pub(super) use super::io_wasm::*; +#[cfg(all(feature = "async", not(target_arch = "wasm32")))] +pub(super) use async_io::*; +#[cfg(all(not(feature = "async"), not(target_arch = "wasm32")))] +pub(super) use blocking::*; diff --git a/src/builtins/runtime/io_wasm.rs b/src/builtins/runtime/io_wasm.rs index 3b2998e2..de2fb436 100644 --- a/src/builtins/runtime/io_wasm.rs +++ b/src/builtins/runtime/io_wasm.rs @@ -5,14 +5,6 @@ use pd_host_function::pd_host_function; use super::HostCallResult; use crate::vm::{CallReturn, HostOpId, Value, Vm, VmError, VmResult}; -pub(crate) struct IoState; - -impl Default for IoState { - fn default() -> Self { - Self - } -} - pub(super) fn poll_builtin_io_op( _vm: &mut Vm, op_id: HostOpId, @@ -23,8 +15,6 @@ pub(super) fn poll_builtin_io_op( )))) } -pub(super) fn close_all_handles(_vm: &mut Vm) {} - /// Opens a file handle for runtime I/O. #[pd_host_function(name = "io::open")] pub(super) fn builtin_io_open( diff --git a/src/builtins/runtime/json.rs b/src/builtins/runtime/json.rs index 504d1602..ab1b3ee8 100644 --- a/src/builtins/runtime/json.rs +++ b/src/builtins/runtime/json.rs @@ -10,6 +10,10 @@ use pd_host_function::pd_host_function; /// Encodes a `Value` into a JSON string. #[pd_host_function(name = "json::encode")] pub(super) fn builtin_json_encode(value: &AnyValue) -> VmResult { + encode_value_to_string(value) +} + +pub(super) fn encode_value_to_string(value: &Value) -> VmResult { let json_value = vm_to_json_value(value)?; serde_json::to_string(&json_value) .map_err(|err| VmError::HostError(format!("json_encode failed: {err}"))) diff --git a/src/builtins/runtime/mod.rs b/src/builtins/runtime/mod.rs index 67fac4fc..96da098e 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -4,12 +4,34 @@ use std::task::{Context, Poll}; use crate::builtins::BuiltinFunction; use crate::vm::{CallOutcome, CallReturn, HostOpId, Value, Vm, VmResult}; +#[cfg(feature = "async")] +use crate::vm::{CaptureAsyncHostContext, HostFutureOutput, VmError}; + +use self::cancellation::{CancellationReason, OperationId, OperationOwner, OperationState}; +use self::error::{RuntimeError, RuntimeErrorCode}; +use self::resource::ResourceHandle; +#[cfg(feature = "sqlite")] +use self::resource::ResourceTypeId; + +type RuntimeOperationPoller = fn(&mut Vm, HostOpId, &mut Context<'_>) -> Poll>; + +const RUNTIME_OPERATION_POLLERS: &[(OperationOwner, RuntimeOperationPoller)] = &[ + #[cfg(not(feature = "async"))] + (OperationOwner::Io, io::poll_builtin_io_op), + #[cfg(feature = "sqlite")] + (OperationOwner::Sqlite, sqlite::poll_pending_op), +]; mod aot; mod bytes; +pub(crate) mod cancellation; +pub(crate) mod context; +mod context_host; pub(crate) mod core; +pub(crate) mod error; +pub(crate) mod event; mod host; -#[cfg(not(target_arch = "wasm32"))] +mod http; mod io; #[cfg(target_arch = "wasm32")] mod io_wasm; @@ -19,12 +41,15 @@ mod map_iter; mod math; pub(crate) mod print; pub(crate) mod regex; +pub(crate) mod resource; +#[cfg(feature = "sqlite")] +mod sqlite; mod typed; -#[cfg(target_arch = "wasm32")] -use io_wasm as io; - -pub(crate) use io::IoState; +pub use http::{HttpConfig, HttpHostExt}; +pub use io::{IoHostExt, IoPolicy}; +#[cfg(feature = "sqlite")] +pub use sqlite::{SqliteHostExt, SqliteLimits, SqlitePolicy}; pub use typed::HostCallResult; use typed::{ AnyValue, IntoBuiltinCallOutcome, IntoHostCallOutcome, NumberValue, UnknownValue, VmArray, @@ -124,8 +149,98 @@ pub(crate) fn execute_builtin_call( } } -pub(crate) fn cancel_builtin_io_op(vm: &mut Vm, op_id: HostOpId) { - io::cancel_pending_op(vm, op_id); +pub(crate) fn cancel_builtin_io_op_with_reason( + vm: &mut Vm, + op_id: HostOpId, + reason: CancellationReason, +) { + let Ok(op_id) = OperationId::from_raw(op_id) else { + return; + }; + let target_resource = vm + .host + .runtime_operations + .get(op_id) + .ok() + .filter(|operation| operation.owner() == OperationOwner::Io) + .and_then(|operation| operation.resource()); + cancel_runtime_operation(vm, op_id, reason); + if let Some(target_resource) = target_resource { + let _ = close_runtime_resource(vm, target_resource, reason); + } +} + +pub(crate) fn cancel_runtime_operation( + vm: &mut Vm, + op_id: OperationId, + reason: CancellationReason, +) { + let payload = vm + .host + .runtime_operations + .get(op_id) + .ok() + .and_then(|operation| operation.payload()); + let _ = vm.host.runtime_operations.cancel(op_id, reason); + if let Some(payload) = payload { + let _ = close_runtime_resource(vm, payload, reason); + } +} + +fn cancel_runtime_operations( + vm: &mut Vm, + operations: Vec, + reason: CancellationReason, +) { + let operations = operations + .into_iter() + .map(|operation| { + let payload = operation.payload(); + (operation, payload) + }) + .collect::>(); + for (operation, _) in &operations { + operation.token().mark_cancelled(reason); + } + for (operation, _) in &operations { + let _ = vm.host.runtime_operations.cancel(operation.id(), reason); + } + for (_, payload) in operations { + if let Some(payload) = payload { + let _ = close_runtime_resource(vm, payload, reason); + } + } +} + +pub(crate) fn close_runtime_resource( + vm: &mut Vm, + handle: ResourceHandle, + reason: CancellationReason, +) -> error::RuntimeResult { + let operations = vm.host.runtime_operations.operations_for_resource(handle); + cancel_runtime_operations(vm, operations, reason); + vm.host.runtime_resources.close(handle, reason) +} + +#[cfg(feature = "sqlite")] +pub(crate) fn close_resources_by_type( + vm: &mut Vm, + resource_type: ResourceTypeId, + reason: CancellationReason, +) { + let handles = vm.host.runtime_resources.handles_of_type(resource_type); + for handle in handles { + let _ = close_runtime_resource(vm, handle, reason); + } +} + +pub(crate) fn cancel_operations_by_owner( + vm: &mut Vm, + owner: OperationOwner, + reason: CancellationReason, +) { + let operations = vm.host.runtime_operations.operations_by_owner(owner); + cancel_runtime_operations(vm, operations, reason); } pub(crate) fn poll_builtin_io_op( @@ -133,11 +248,63 @@ pub(crate) fn poll_builtin_io_op( op_id: HostOpId, cx: &mut Context<'_>, ) -> Poll> { - io::poll_builtin_io_op(vm, op_id, cx) + let operation_id = match OperationId::from_raw(op_id) { + Ok(operation_id) => operation_id, + Err(error) => { + return Poll::Ready(Err(crate::vm::VmError::HostError(error.to_string()))); + } + }; + let operation = match vm.host.runtime_operations.get(operation_id) { + Ok(operation) => operation, + Err(error) => { + return Poll::Ready(Err(crate::vm::VmError::HostError(error.to_string()))); + } + }; + if let Err(error) = operation.token().check() { + let reason = operation + .token() + .reason() + .unwrap_or(CancellationReason::Requested); + cancel_builtin_io_op_with_reason(vm, op_id, reason); + return Poll::Ready(Err(crate::vm::VmError::HostError(error.to_string()))); + } + + let Some((_, poller)) = RUNTIME_OPERATION_POLLERS + .iter() + .find(|(owner, _)| *owner == operation.owner()) + else { + return Poll::Ready(Err(crate::vm::VmError::HostError(format!( + "runtime operation owner {:?} is unavailable in this build", + operation.owner() + )))); + }; + let result = poller(vm, op_id, cx); + + match result { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(values)) => { + let _ = vm.host.runtime_operations.complete(operation_id); + Poll::Ready(Ok(values)) + } + Poll::Ready(Err(error)) => { + if let Some(reason) = operation.token().reason() { + cancel_builtin_io_op_with_reason(vm, op_id, reason); + return Poll::Ready(Err(error)); + } + let runtime_error = RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "runtime::operation", + error.to_string(), + ) + .with_value(op_id); + let _ = vm.host.runtime_operations.fail(operation_id, runtime_error); + Poll::Ready(Err(error)) + } + } } pub(crate) fn close_all_handles(vm: &mut Vm) { - io::close_all_handles(vm); + vm.host.reset_for_reuse(); } #[cfg(test)] diff --git a/src/builtins/runtime/resource.rs b/src/builtins/runtime/resource.rs new file mode 100644 index 00000000..49500e07 --- /dev/null +++ b/src/builtins/runtime/resource.rs @@ -0,0 +1,569 @@ +use std::any::Any; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::vm::Value; + +use super::cancellation::CancellationReason; +use super::error::{RuntimeError, RuntimeErrorCode, RuntimeResult}; + +pub const DEFAULT_MAX_RESOURCES: usize = 1024; + +const HANDLE_TYPE_BITS: u64 = 8; +const HANDLE_GENERATION_BITS: u64 = 17; +const HANDLE_SLOT_BITS: u64 = 18; +const HANDLE_ARENA_BITS: u64 = 63 - HANDLE_TYPE_BITS - HANDLE_GENERATION_BITS - HANDLE_SLOT_BITS; + +const HANDLE_TYPE_SHIFT: u64 = 0; +const HANDLE_GENERATION_SHIFT: u64 = HANDLE_TYPE_BITS; +const HANDLE_SLOT_SHIFT: u64 = HANDLE_GENERATION_SHIFT + HANDLE_GENERATION_BITS; +const HANDLE_ARENA_SHIFT: u64 = HANDLE_SLOT_SHIFT + HANDLE_SLOT_BITS; + +const HANDLE_TYPE_MASK: u64 = (1 << HANDLE_TYPE_BITS) - 1; +const HANDLE_GENERATION_MASK: u64 = (1 << HANDLE_GENERATION_BITS) - 1; +const HANDLE_SLOT_MASK: u64 = (1 << HANDLE_SLOT_BITS) - 1; +const HANDLE_ARENA_MASK: u64 = (1 << HANDLE_ARENA_BITS) - 1; + +/// Process-wide monotonic arena identity source. Arena identities are not +/// recycled, so a handle from a dropped VM cannot resolve in a later VM. +static NEXT_ARENA_ID: AtomicU64 = AtomicU64::new(1); + +/// Stable resource type identity carried by every opaque handle. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ResourceTypeId(u16); + +impl ResourceTypeId { + pub const IO_FILE: Self = Self(1); + + #[cfg_attr(not(feature = "sqlite"), allow(dead_code))] + pub const SQLITE_CONNECTION: Self = Self(5); + #[cfg_attr(feature = "async", allow(dead_code))] + pub const CALLBACK: Self = Self(6); + + pub const fn raw(self) -> u16 { + self.0 + } +} + +/// A positive VM integer identifying one typed resource without exposing it. +/// +/// The token carries arena, slot, generation, and resource-type identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ResourceHandle(u64); + +impl ResourceHandle { + pub fn as_value(self) -> Value { + Value::Int(self.0 as i64) + } + + pub fn from_value(value: &Value) -> RuntimeResult { + let Value::Int(raw) = value else { + return Err(invalid_handle("resource handle must be an integer token")); + }; + if *raw <= 0 { + return Err(invalid_handle("resource handle must be a positive token")); + } + Self::from_encoded(*raw as u64) + } + + pub const fn resource_type(self) -> ResourceTypeId { + ResourceTypeId(((self.0 >> HANDLE_TYPE_SHIFT) & HANDLE_TYPE_MASK) as u16) + } + + const fn arena_id(self) -> u64 { + (self.0 >> HANDLE_ARENA_SHIFT) & HANDLE_ARENA_MASK + } + + const fn slot_identity(self) -> u64 { + (self.0 >> HANDLE_SLOT_SHIFT) & HANDLE_SLOT_MASK + } + + const fn generation(self) -> u64 { + (self.0 >> HANDLE_GENERATION_SHIFT) & HANDLE_GENERATION_MASK + } + + fn slot_index(self) -> RuntimeResult { + usize::try_from(self.slot_identity() - 1) + .map_err(|_| invalid_handle("resource handle slot is out of range")) + } + + fn from_encoded(encoded: u64) -> RuntimeResult { + let handle = Self(encoded); + if encoded == 0 + || encoded > i64::MAX as u64 + || handle.arena_id() == 0 + || handle.slot_identity() == 0 + || handle.generation() == 0 + || handle.resource_type().raw() == 0 + { + return Err(invalid_handle( + "resource handle token has an invalid encoding", + )); + } + Ok(handle) + } + + fn encode( + arena_id: u64, + slot_index: usize, + generation: u64, + resource_type: ResourceTypeId, + ) -> RuntimeResult { + let slot_identity = u64::try_from(slot_index) + .ok() + .and_then(|slot| slot.checked_add(1)) + .ok_or_else(|| invalid_handle("resource slot identity overflowed"))?; + if arena_id == 0 + || arena_id > HANDLE_ARENA_MASK + || slot_identity > HANDLE_SLOT_MASK + || generation == 0 + || generation > HANDLE_GENERATION_MASK + || resource_type.raw() == 0 + || u64::from(resource_type.raw()) > HANDLE_TYPE_MASK + { + return Err(invalid_handle( + "resource handle components are out of range", + )); + } + let encoded = (arena_id << HANDLE_ARENA_SHIFT) + | (slot_identity << HANDLE_SLOT_SHIFT) + | (generation << HANDLE_GENERATION_SHIFT) + | (u64::from(resource_type.raw()) << HANDLE_TYPE_SHIFT); + Self::from_encoded(encoded) + } +} + +type ErasedResource = Box; +type ResourceCleanup = + Box RuntimeResult<()> + Send + 'static>; + +struct ResourceSlot { + generation: u32, + resource_type: ResourceTypeId, + value: Option, + cleanup: Option, +} + +/// VM-local bounded arena for typed opaque host resources. +pub struct ResourceArena { + arena_id: u64, + max_entries: usize, + slots: Vec, + vacant_slots: Vec, + active_entries: usize, +} + +impl ResourceArena { + pub fn with_limit(max_entries: usize) -> RuntimeResult { + if max_entries == 0 || max_entries > HANDLE_SLOT_MASK as usize { + return Err(RuntimeError::new( + RuntimeErrorCode::InvalidConfiguration, + "resource::arena", + format!( + "resource arena capacity must be between 1 and {}", + HANDLE_SLOT_MASK + ), + ) + .with_limit(HANDLE_SLOT_MASK as usize)); + } + let arena_id = NEXT_ARENA_ID + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |arena_id| { + (arena_id <= HANDLE_ARENA_MASK).then_some(arena_id + 1) + }) + .map_err(|_| { + RuntimeError::new( + RuntimeErrorCode::ResourceIdExhausted, + "resource::arena", + "resource arena identity space is exhausted", + ) + })?; + Ok(Self { + arena_id, + max_entries, + slots: Vec::new(), + vacant_slots: Vec::new(), + active_entries: 0, + }) + } + + #[cfg_attr(feature = "async", allow(dead_code))] + pub fn insert( + &mut self, + resource_type: ResourceTypeId, + value: T, + ) -> RuntimeResult + where + T: Any + Send + 'static, + { + self.allocate(resource_type, Box::new(value), None) + } + + pub fn insert_with_cleanup( + &mut self, + resource_type: ResourceTypeId, + value: T, + cleanup: F, + ) -> RuntimeResult + where + T: Any + Send + 'static, + F: FnOnce(T, CancellationReason) -> RuntimeResult<()> + Send + 'static, + { + let erased_cleanup: ResourceCleanup = Box::new(move |value, reason| { + let value = value.downcast::().map_err(|_| { + RuntimeError::new( + RuntimeErrorCode::ResourceTypeMismatch, + "resource::cleanup", + "resource cleanup received the wrong concrete type", + ) + })?; + cleanup(*value, reason) + }); + self.allocate(resource_type, Box::new(value), Some(erased_cleanup)) + } + + #[cfg_attr(not(feature = "sqlite"), allow(dead_code))] + pub fn count_type(&self, resource_type: ResourceTypeId) -> usize { + self.slots + .iter() + .filter(|slot| slot.resource_type == resource_type && slot.value.is_some()) + .count() + } + + #[cfg(feature = "sqlite")] + pub fn handles_of_type(&self, resource_type: ResourceTypeId) -> Vec { + self.slots + .iter() + .enumerate() + .filter(|(_, slot)| slot.resource_type == resource_type && slot.value.is_some()) + .filter_map(|(slot_index, slot)| { + ResourceHandle::encode( + self.arena_id, + slot_index, + u64::from(slot.generation), + slot.resource_type, + ) + .ok() + }) + .collect() + } + + pub fn get(&self, handle: ResourceHandle, expected_type: ResourceTypeId) -> RuntimeResult<&T> + where + T: Any + Send + 'static, + { + self.active_slot(handle, expected_type)? + .value + .as_ref() + .and_then(|value| value.downcast_ref::()) + .ok_or_else(|| type_mismatch(handle, expected_type)) + } + + #[cfg_attr(feature = "async", allow(dead_code))] + pub fn get_mut( + &mut self, + handle: ResourceHandle, + expected_type: ResourceTypeId, + ) -> RuntimeResult<&mut T> + where + T: Any + Send + 'static, + { + self.active_slot_mut(handle, expected_type)? + .value + .as_mut() + .and_then(|value| value.downcast_mut::()) + .ok_or_else(|| type_mismatch(handle, expected_type)) + } + + pub fn close( + &mut self, + handle: ResourceHandle, + reason: CancellationReason, + ) -> RuntimeResult { + let slot_index = self.validate_handle_identity(handle)?; + let (value, cleanup, reusable) = { + let slot = &mut self.slots[slot_index]; + validate_slot_identity(slot, handle)?; + if slot.resource_type != handle.resource_type() { + return Err(type_mismatch(handle, slot.resource_type)); + } + let Some(value) = slot.value.take() else { + return Ok(CloseStatus::AlreadyClosed); + }; + self.active_entries -= 1; + ( + value, + slot.cleanup.take(), + u64::from(slot.generation) < HANDLE_GENERATION_MASK, + ) + }; + if reusable { + self.vacant_slots.push(slot_index); + } + let result = if let Some(cleanup) = cleanup { + cleanup(value, reason) + } else { + drop(value); + Ok(()) + }; + result.map(|()| CloseStatus::Closed).map_err(|error| { + RuntimeError::new( + RuntimeErrorCode::ResourceCleanupFailed, + "resource::close", + error.to_string(), + ) + .with_value(handle.0) + }) + } + + pub fn close_all(&mut self, reason: CancellationReason) -> RuntimeResult { + let handles = self + .slots + .iter() + .enumerate() + .filter_map(|(slot_index, slot)| { + slot.value.as_ref().map(|_| { + ResourceHandle::encode( + self.arena_id, + slot_index, + u64::from(slot.generation), + slot.resource_type, + ) + .expect("active resource slot must have an encodable handle") + }) + }) + .collect::>(); + let mut closed = 0; + let mut first_error = None; + for handle in handles { + match self.close(handle, reason) { + Ok(CloseStatus::Closed) => closed += 1, + Ok(CloseStatus::AlreadyClosed) => {} + Err(error) => { + first_error.get_or_insert(error); + } + } + } + match first_error { + Some(error) => Err(error), + None => Ok(closed), + } + } + + fn allocate( + &mut self, + resource_type: ResourceTypeId, + value: ErasedResource, + cleanup: Option, + ) -> RuntimeResult { + if resource_type.raw() == 0 || u64::from(resource_type.raw()) > HANDLE_TYPE_MASK { + return Err(RuntimeError::new( + RuntimeErrorCode::ResourceTypeMismatch, + "resource::insert", + "resource type id is outside the handle encoding range", + )); + } + if self.active_entries >= self.max_entries { + return Err(RuntimeError::new( + RuntimeErrorCode::ResourceLimitExceeded, + "resource::insert", + "resource arena capacity has been reached", + ) + .with_limit(self.max_entries)); + } + + let (slot_index, generation) = if let Some(slot_index) = self.vacant_slots.pop() { + let slot = &mut self.slots[slot_index]; + let generation = slot + .generation + .checked_add(1) + .filter(|generation| u64::from(*generation) <= HANDLE_GENERATION_MASK) + .expect("only reusable resource generations enter the vacant list"); + slot.generation = generation; + slot.resource_type = resource_type; + slot.value = Some(value); + slot.cleanup = cleanup; + (slot_index, generation) + } else { + if self.slots.len() >= self.max_entries { + return Err(RuntimeError::new( + RuntimeErrorCode::ResourceIdExhausted, + "resource::insert", + "resource slot generation space is exhausted", + )); + } + let slot_index = self.slots.len(); + let generation = 1; + self.slots.push(ResourceSlot { + generation, + resource_type, + value: Some(value), + cleanup, + }); + (slot_index, generation) + }; + self.active_entries += 1; + ResourceHandle::encode( + self.arena_id, + slot_index, + u64::from(generation), + resource_type, + ) + } + + fn validate_handle_identity(&self, handle: ResourceHandle) -> RuntimeResult { + if handle.arena_id() != self.arena_id { + return Err(wrong_arena(handle)); + } + let slot_index = handle.slot_index()?; + if slot_index >= self.slots.len() { + return Err(stale_handle(handle)); + } + Ok(slot_index) + } + + fn active_slot( + &self, + handle: ResourceHandle, + expected_type: ResourceTypeId, + ) -> RuntimeResult<&ResourceSlot> { + validate_type(handle, expected_type)?; + let slot_index = self.validate_handle_identity(handle)?; + let slot = &self.slots[slot_index]; + validate_slot(slot, handle, expected_type)?; + Ok(slot) + } + + #[cfg_attr(feature = "async", allow(dead_code))] + fn active_slot_mut( + &mut self, + handle: ResourceHandle, + expected_type: ResourceTypeId, + ) -> RuntimeResult<&mut ResourceSlot> { + validate_type(handle, expected_type)?; + let slot_index = self.validate_handle_identity(handle)?; + let slot = &mut self.slots[slot_index]; + validate_slot(slot, handle, expected_type)?; + Ok(slot) + } +} + +impl Default for ResourceArena { + fn default() -> Self { + Self::with_limit(DEFAULT_MAX_RESOURCES) + .expect("default resource arena configuration should be valid") + } +} + +impl Drop for ResourceArena { + fn drop(&mut self) { + let _ = self.close_all(CancellationReason::VmReset); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CloseStatus { + Closed, + AlreadyClosed, +} + +fn validate_type(handle: ResourceHandle, expected_type: ResourceTypeId) -> RuntimeResult<()> { + if handle.resource_type() != expected_type { + return Err(type_mismatch(handle, expected_type)); + } + Ok(()) +} + +fn validate_slot_identity(slot: &ResourceSlot, handle: ResourceHandle) -> RuntimeResult<()> { + if u64::from(slot.generation) != handle.generation() { + return Err(stale_handle(handle)); + } + Ok(()) +} + +fn validate_slot( + slot: &ResourceSlot, + handle: ResourceHandle, + expected_type: ResourceTypeId, +) -> RuntimeResult<()> { + validate_slot_identity(slot, handle)?; + if slot.resource_type != expected_type { + return Err(type_mismatch(handle, expected_type)); + } + if slot.value.is_none() { + return Err(already_closed_error(handle)); + } + Ok(()) +} + +fn invalid_handle(message: &'static str) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::InvalidResourceHandle, + "resource::handle", + message, + ) +} + +fn wrong_arena(handle: ResourceHandle) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::ResourceHandleWrongTable, + "resource::handle", + "resource handle does not belong to this VM arena", + ) + .with_value(handle.0) +} + +fn stale_handle(handle: ResourceHandle) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::ResourceStale, + "resource::handle", + "resource handle refers to a stale slot generation", + ) + .with_value(handle.0) +} + +fn already_closed_error(handle: ResourceHandle) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::ResourceAlreadyClosed, + "resource::handle", + "resource is already closed", + ) + .with_value(handle.0) +} + +fn type_mismatch(handle: ResourceHandle, expected: ResourceTypeId) -> RuntimeError { + RuntimeError::new( + RuntimeErrorCode::ResourceTypeMismatch, + "resource::handle", + format!( + "resource type {} does not match expected type {}", + handle.resource_type().raw(), + expected.raw() + ), + ) + .with_value(handle.0) +} + +#[cfg(test)] +mod tests { + use super::{CancellationReason, ResourceArena, ResourceTypeId}; + + #[test] + fn vacant_slot_reuse_increments_the_generation() { + let mut arena = ResourceArena::with_limit(1).expect("arena should be valid"); + let first = arena + .insert(ResourceTypeId::IO_FILE, 1_u8) + .expect("first resource should be inserted"); + assert_eq!( + arena + .close(first, CancellationReason::ResourceClosed) + .expect("first resource should close"), + super::CloseStatus::Closed + ); + + let replacement = arena + .insert(ResourceTypeId::IO_FILE, 2_u8) + .expect("vacant slot should be reused"); + + assert_eq!(replacement.slot_identity(), first.slot_identity()); + assert_eq!(replacement.generation(), first.generation() + 1); + } +} diff --git a/src/builtins/runtime/sqlite.rs b/src/builtins/runtime/sqlite.rs new file mode 100644 index 00000000..8d6ae781 --- /dev/null +++ b/src/builtins/runtime/sqlite.rs @@ -0,0 +1,1252 @@ +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Mutex, mpsc}; +use std::task::{Context, Poll, Waker}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use pd_host_function::pd_host_function; +use rusqlite::hooks::{AuthAction, AuthContext, Authorization}; +use rusqlite::limits::Limit; +use rusqlite::types::{Value as SqlValue, ValueRef}; +use rusqlite::{Connection, OpenFlags, TransactionBehavior, params_from_iter}; + +use super::cancellation::{ + CancellationReason, CancellationToken, OperationId, OperationOwner, OperationStatus, +}; +use super::error::{RuntimeError, RuntimeErrorCode}; +use super::resource::{ResourceHandle, ResourceTypeId}; +use super::typed::{VmArrayRef, VmMapRef}; +use super::{HostCallResult, VmMap}; +use crate::vm::{CallReturn, HostOpId, Value, Vm, VmError, VmResult}; + +const SQLITE_PROGRESS_STEPS: i32 = 1_000; +const SQLITE_CLOSE_GRACE: Duration = Duration::from_millis(100); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SqliteLimits { + pub max_connections: usize, + pub max_statements: usize, + pub max_rows: usize, + pub max_columns: usize, + pub max_result_bytes: usize, + pub max_statement_bytes: usize, + pub max_parameters: usize, + pub max_parameter_bytes: usize, + pub max_pending_operations: usize, + pub max_transaction_ms: u64, + pub busy_timeout_ms: u64, +} + +impl Default for SqliteLimits { + fn default() -> Self { + Self { + max_connections: 16, + max_statements: 128, + max_rows: 1_000, + max_columns: 128, + max_result_bytes: 4 * 1024 * 1024, + max_statement_bytes: 1024 * 1024, + max_parameters: 128, + max_parameter_bytes: 1024 * 1024, + max_pending_operations: 32, + max_transaction_ms: 5_000, + busy_timeout_ms: 5_000, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SqlitePolicy { + pub database_root: Option, + pub allow_unsafe_sql: bool, + pub limits: SqliteLimits, +} + +struct SqliteHostState { + policy: SqlitePolicy, +} + +/// SQLite host configuration owned by the SQLite host implementation. +#[allow(dead_code)] +pub trait SqliteHostExt { + fn configure_sqlite(&mut self, policy: SqlitePolicy); + fn clear_sqlite_configuration(&mut self); +} + +impl SqliteHostExt for Vm { + fn configure_sqlite(&mut self, policy: SqlitePolicy) { + super::cancel_operations_by_owner( + self, + OperationOwner::Sqlite, + CancellationReason::ResourceClosed, + ); + super::close_resources_by_type( + self, + ResourceTypeId::SQLITE_CONNECTION, + CancellationReason::ResourceClosed, + ); + self.host + .set_host_function_state(SqliteHostState { policy }); + } + + fn clear_sqlite_configuration(&mut self) { + super::cancel_operations_by_owner( + self, + OperationOwner::Sqlite, + CancellationReason::ResourceClosed, + ); + super::close_resources_by_type( + self, + ResourceTypeId::SQLITE_CONNECTION, + CancellationReason::ResourceClosed, + ); + self.host.remove_host_function_state::(); + } +} + +fn sqlite_policy(vm: &Vm) -> SqlitePolicy { + vm.host + .host_function_state::() + .map_or_else(SqlitePolicy::default, |state| state.policy.clone()) +} + +/// Returns the affected-row count from a SQLite result envelope. +#[pd_host_function(name = "sqlite::rows_affected")] +pub(super) fn builtin_sqlite_rows_affected_impl(value: VmMapRef<'_>) -> VmResult { + match value.get(&Value::string("rows_affected")) { + Some(Value::Int(value)) => Ok(*value), + Some(_) => Err(VmError::TypeMismatch("SQLite rows_affected integer")), + None => Ok(0), + } +} + +/// Returns the truncation flag from a SQLite query result envelope. +#[pd_host_function(name = "sqlite::truncated")] +pub(super) fn builtin_sqlite_truncated_impl(value: VmMapRef<'_>) -> VmResult { + match value.get(&Value::string("truncated")) { + Some(Value::Bool(value)) => Ok(*value), + Some(_) => Err(VmError::TypeMismatch("SQLite truncated boolean")), + None => Ok(false), + } +} + +/// Returns the continuation cursor from a SQLite query result envelope. +#[pd_host_function(name = "sqlite::next_cursor")] +pub(super) fn builtin_sqlite_next_cursor_impl(value: VmMapRef<'_>) -> VmResult { + match value.get(&Value::string("next_cursor")) { + Some(Value::Int(value)) => Ok(*value), + Some(_) => Err(VmError::TypeMismatch("SQLite next_cursor integer")), + None => Ok(0), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OpenMode { + Memory, + ReadOnly, + ReadWrite, + ReadWriteCreate, +} + +struct OpenOptions { + path: String, + mode: OpenMode, + root: Option, + limits: SqliteLimits, + allow_unsafe_sql: bool, +} + +struct ConnectionSlot { + connection: Mutex, + execution: Mutex<()>, + active_operation: Mutex>, + interrupt: Arc, + limits: SqliteLimits, + allow_unsafe_sql: bool, +} + +struct PendingResult { + receiver: mpsc::Receiver>, + worker: Option>, + waker: Arc>>, +} + +fn runtime_error(error: RuntimeError) -> VmError { + VmError::HostError(error.to_string()) +} + +fn operation_id(op_id: HostOpId) -> VmResult { + OperationId::from_raw(op_id).map_err(runtime_error) +} + +fn handle_value(handle: ResourceHandle) -> i64 { + match handle.as_value() { + Value::Int(value) => value, + _ => unreachable!("resource handles are integer values"), + } +} + +fn sqlite_handle(raw: i64) -> VmResult { + let handle = ResourceHandle::from_value(&Value::Int(raw)) + .map_err(|error| VmError::HostError(format!("unknown SQLite database handle: {error}")))?; + if handle.resource_type() != ResourceTypeId::SQLITE_CONNECTION { + return Err(VmError::HostError( + "unknown SQLite database handle (wrong resource type)".to_string(), + )); + } + Ok(handle) +} + +fn lookup_connection(vm: &Vm, raw: i64) -> VmResult<(ResourceHandle, Arc)> { + let handle = sqlite_handle(raw)?; + let slot = vm + .host + .runtime_resources + .get::>(handle, ResourceTypeId::SQLITE_CONNECTION) + .map_err(|error| VmError::HostError(format!("unknown SQLite database: {error}")))?; + Ok((handle, Arc::clone(slot))) +} + +fn map_value<'a>(map: &'a VmMap, key: &str) -> Option<&'a Value> { + map.get(&Value::string(key)) +} + +fn required_string(map: &VmMap, key: &str) -> VmResult { + match map_value(map, key) { + Some(Value::String(value)) if !value.is_empty() => Ok(value.as_ref().clone()), + Some(Value::String(_)) => Err(VmError::HostError(format!( + "SQLite {key} must not be empty" + ))), + Some(_) => Err(VmError::TypeMismatch("SQLite option string")), + None => Err(VmError::HostError(format!("missing SQLite {key}"))), + } +} + +fn optional_string(map: &VmMap, key: &str) -> VmResult> { + match map_value(map, key) { + Some(Value::String(value)) => Ok(Some(value.as_ref().clone())), + Some(Value::Null) | None => Ok(None), + Some(_) => Err(VmError::TypeMismatch("SQLite option string")), + } +} + +fn parse_positive_usize(value: &Value, label: &str) -> VmResult { + let Value::Int(value) = value else { + return Err(VmError::TypeMismatch("SQLite limit integer")); + }; + if *value <= 0 { + return Err(VmError::HostError(format!( + "SQLite {label} must be positive" + ))); + } + usize::try_from(*value).map_err(|_| VmError::HostError(format!("SQLite {label} is too large"))) +} + +fn parse_positive_u64(value: &Value, label: &str) -> VmResult { + let Value::Int(value) = value else { + return Err(VmError::TypeMismatch("SQLite limit integer")); + }; + if *value <= 0 { + return Err(VmError::HostError(format!( + "SQLite {label} must be positive" + ))); + } + u64::try_from(*value).map_err(|_| VmError::HostError(format!("SQLite {label} is too large"))) +} + +fn parse_limits(value: Option<&Value>, ceiling: SqliteLimits) -> VmResult { + let Some(value) = value else { + return Ok(ceiling); + }; + let Value::Map(map) = value else { + return Err(VmError::TypeMismatch("SQLite limits map")); + }; + let mut limits = ceiling; + for (key, value) in map.iter() { + let Value::String(key) = key else { + return Err(VmError::TypeMismatch("SQLite limit name")); + }; + match key.as_str() { + "max_connections" => { + limits.max_connections = + parse_positive_usize(value, key)?.min(ceiling.max_connections) + } + "max_statements" => { + limits.max_statements = + parse_positive_usize(value, key)?.min(ceiling.max_statements) + } + "max_rows" => limits.max_rows = parse_positive_usize(value, key)?.min(ceiling.max_rows), + "max_columns" => { + limits.max_columns = parse_positive_usize(value, key)?.min(ceiling.max_columns) + } + "max_result_bytes" => { + limits.max_result_bytes = + parse_positive_usize(value, key)?.min(ceiling.max_result_bytes) + } + "max_statement_bytes" => { + limits.max_statement_bytes = + parse_positive_usize(value, key)?.min(ceiling.max_statement_bytes) + } + "max_parameters" => { + limits.max_parameters = + parse_positive_usize(value, key)?.min(ceiling.max_parameters) + } + "max_parameter_bytes" => { + limits.max_parameter_bytes = + parse_positive_usize(value, key)?.min(ceiling.max_parameter_bytes) + } + "max_pending_operations" => { + limits.max_pending_operations = + parse_positive_usize(value, key)?.min(ceiling.max_pending_operations) + } + "max_transaction_ms" => { + limits.max_transaction_ms = + parse_positive_u64(value, key)?.min(ceiling.max_transaction_ms) + } + "busy_timeout_ms" => { + limits.busy_timeout_ms = + parse_positive_u64(value, key)?.min(ceiling.busy_timeout_ms) + } + _ => { + return Err(VmError::HostError(format!("unknown SQLite limit {key}"))); + } + } + } + Ok(limits) +} + +fn parse_open_options(vm: &Vm, options: &VmMap) -> VmResult { + let path = required_string(options, "path")?; + let mode = match optional_string(options, "mode")?.as_deref() { + Some("memory") => OpenMode::Memory, + Some("read_only") => OpenMode::ReadOnly, + Some("read_write") => OpenMode::ReadWrite, + Some("read_write_create") | None => OpenMode::ReadWriteCreate, + Some(mode) => { + return Err(VmError::HostError(format!( + "unknown SQLite open mode {mode}" + ))); + } + }; + let policy = sqlite_policy(vm); + let configured_root = policy.database_root.as_deref().map(PathBuf::from); + if let Some(requested_root) = optional_string(options, "root")? { + let requested_root = PathBuf::from(requested_root); + if configured_root.as_ref() != Some(&requested_root) { + return Err(VmError::HostError( + "SQLite root must match the embedding policy".to_string(), + )); + } + } + if mode != OpenMode::Memory && configured_root.is_none() { + return Err(VmError::HostError( + "SQLite database root is not configured".to_string(), + )); + } + let limits = parse_limits(map_value(options, "limits"), policy.limits)?; + Ok(OpenOptions { + path, + mode, + root: configured_root, + limits, + allow_unsafe_sql: policy.allow_unsafe_sql, + }) +} + +fn parse_query_limits(value: &VmMap, ceiling: SqliteLimits) -> VmResult { + parse_limits(Some(&Value::Map(Arc::new(value.clone()))), ceiling) +} + +fn validate_relative_path(path: &Path) -> VmResult<()> { + if path.as_os_str().is_empty() || path.is_absolute() { + return Err(VmError::HostError( + "SQLite database path must be a non-empty relative path".to_string(), + )); + } + if path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(VmError::HostError( + "SQLite database path must stay below its configured root".to_string(), + )); + } + Ok(()) +} + +fn canonical_root(root: &Path) -> VmResult { + if !root.is_absolute() { + return Err(VmError::HostError( + "SQLite database root must be absolute".to_string(), + )); + } + fs::canonicalize(root) + .map_err(|error| VmError::HostError(format!("invalid SQLite database root: {error}"))) +} + +fn resolve_database_path(options: &OpenOptions) -> VmResult> { + if options.mode == OpenMode::Memory { + if options.path != ":memory:" { + return Err(VmError::HostError( + "SQLite memory mode requires path ':memory:'".to_string(), + )); + } + return Ok(None); + } + if options.path == ":memory:" { + return Err(VmError::HostError( + "SQLite ':memory:' requires memory open mode".to_string(), + )); + } + let root = options + .root + .as_deref() + .ok_or_else(|| VmError::HostError("SQLite database root is required".to_string()))?; + let root = canonical_root(root)?; + let relative = Path::new(&options.path); + validate_relative_path(relative)?; + let candidate = root.join(relative); + let canonical = if candidate.exists() { + fs::canonicalize(&candidate) + .map_err(|error| VmError::HostError(format!("invalid SQLite database path: {error}")))? + } else { + if options.mode != OpenMode::ReadWriteCreate { + return Err(VmError::HostError(format!( + "SQLite database does not exist: {}", + candidate.display() + ))); + } + let parent = candidate + .parent() + .ok_or_else(|| VmError::HostError("SQLite database path has no parent".to_string()))?; + let canonical_parent = fs::canonicalize(parent).map_err(|error| { + VmError::HostError(format!("invalid SQLite database parent: {error}")) + })?; + let file_name = candidate.file_name().ok_or_else(|| { + VmError::HostError("SQLite database path has no file name".to_string()) + })?; + canonical_parent.join(file_name) + }; + if !canonical.starts_with(&root) { + return Err(VmError::HostError( + "SQLite database path escapes its configured root".to_string(), + )); + } + Ok(Some(canonical)) +} + +fn sqlite_limit(value: usize, label: &str) -> VmResult { + i32::try_from(value) + .map_err(|_| VmError::HostError(format!("SQLite {label} exceeds engine limits"))) +} + +fn install_connection_limits(connection: &Connection, limits: SqliteLimits) -> VmResult<()> { + let max_value_bytes = limits.max_result_bytes.max(limits.max_parameter_bytes); + connection.set_limit( + Limit::SQLITE_LIMIT_LENGTH, + sqlite_limit(max_value_bytes, "value byte limit")?, + ); + connection.set_limit( + Limit::SQLITE_LIMIT_SQL_LENGTH, + sqlite_limit(limits.max_statement_bytes, "statement byte limit")?, + ); + connection.set_limit( + Limit::SQLITE_LIMIT_COLUMN, + sqlite_limit(limits.max_columns, "column limit")?, + ); + connection.set_limit( + Limit::SQLITE_LIMIT_VARIABLE_NUMBER, + sqlite_limit(limits.max_parameters, "parameter count limit")?, + ); + Ok(()) +} + +fn install_authorizer(connection: &Connection, allow_unsafe_sql: bool) { + connection.authorizer(Some(move |context: AuthContext<'_>| { + if allow_unsafe_sql { + return Authorization::Allow; + } + match context.action { + AuthAction::Attach { .. } + | AuthAction::Detach { .. } + | AuthAction::Pragma { .. } + | AuthAction::CreateVtable { .. } + | AuthAction::DropVtable { .. } + | AuthAction::Unknown { .. } => Authorization::Deny, + AuthAction::Function { function_name } + if function_name.eq_ignore_ascii_case("load_extension") => + { + Authorization::Deny + } + _ => Authorization::Allow, + } + })); +} + +fn open_connection(options: &OpenOptions) -> VmResult { + let path = resolve_database_path(options)?; + let flags = match options.mode { + OpenMode::Memory => OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, + OpenMode::ReadOnly => OpenFlags::SQLITE_OPEN_READ_ONLY, + OpenMode::ReadWrite => OpenFlags::SQLITE_OPEN_READ_WRITE, + OpenMode::ReadWriteCreate => { + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE + } + } | OpenFlags::SQLITE_OPEN_NO_MUTEX; + let connection = match path { + Some(path) => Connection::open_with_flags(path, flags), + None => Connection::open_in_memory_with_flags(flags), + } + .map_err(sqlite_error)?; + connection + .busy_timeout(Duration::from_millis(options.limits.busy_timeout_ms)) + .map_err(sqlite_error)?; + install_connection_limits(&connection, options.limits)?; + install_authorizer(&connection, options.allow_unsafe_sql); + Ok(connection) +} + +fn normalized_sql(sql: &str) -> VmResult { + let bytes = sql.as_bytes(); + let mut out = String::with_capacity(sql.len()); + let mut index = 0; + let mut quote = None; + let mut statement_ended = false; + while index < bytes.len() { + let byte = bytes[index]; + if let Some(active_quote) = quote { + if byte == active_quote { + if index + 1 < bytes.len() && bytes[index + 1] == active_quote { + index += 2; + continue; + } + quote = None; + } + index += 1; + continue; + } + if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + out.push(' '); + index += 1; + continue; + } + if byte == b'-' && index + 1 < bytes.len() && bytes[index + 1] == b'-' { + index += 2; + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + out.push(' '); + continue; + } + if byte == b'/' && index + 1 < bytes.len() && bytes[index + 1] == b'*' { + index += 2; + while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') { + index += 1; + } + if index + 1 >= bytes.len() { + return Err(VmError::HostError( + "SQLite SQL contains an unterminated comment".to_string(), + )); + } + index += 2; + out.push(' '); + continue; + } + if byte == b';' { + statement_ended = true; + index += 1; + continue; + } + if statement_ended && !byte.is_ascii_whitespace() { + return Err(VmError::HostError( + "multiple SQLite statements are not allowed".to_string(), + )); + } + out.push((byte as char).to_ascii_lowercase()); + index += 1; + } + if quote.is_some() { + return Err(VmError::HostError( + "SQLite SQL contains an unterminated quote".to_string(), + )); + } + Ok(out) +} + +fn validate_sql(sql: &str, limits: SqliteLimits, allow_unsafe_sql: bool) -> VmResult<()> { + if sql.is_empty() || sql.len() > limits.max_statement_bytes || sql.as_bytes().contains(&0) { + return Err(VmError::HostError(format!( + "SQLite statement exceeds the configured {} byte limit or is invalid", + limits.max_statement_bytes + ))); + } + let normalized = normalized_sql(sql)?; + if allow_unsafe_sql { + return Ok(()); + } + let first = normalized.split_whitespace().next().unwrap_or_default(); + if matches!( + first, + "attach" + | "detach" + | "pragma" + | "vacuum" + | "begin" + | "commit" + | "rollback" + | "savepoint" + | "release" + ) { + return Err(VmError::HostError(format!( + "SQLite statement {first} is not allowed" + ))); + } + if normalized + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .any(|token| token == "load_extension") + { + return Err(VmError::HostError( + "SQLite extension loading is disabled".to_string(), + )); + } + Ok(()) +} + +fn sqlite_params(values: VmArrayRef<'_>, limits: SqliteLimits) -> VmResult> { + if values.len() > limits.max_parameters { + return Err(VmError::HostError( + "SQLite parameter count exceeds the configured limit".to_string(), + )); + } + let mut bytes = 0usize; + let mut params = Vec::with_capacity(values.len()); + for value in values { + let sql_value = match value { + Value::Null => SqlValue::Null, + Value::Int(value) => SqlValue::Integer(*value), + Value::Float(value) => SqlValue::Real(*value), + Value::String(value) => { + bytes = bytes.saturating_add(value.len()); + SqlValue::Text(value.as_ref().clone()) + } + Value::Bytes(value) => { + bytes = bytes.saturating_add(value.len()); + SqlValue::Blob(value.as_ref().clone()) + } + _ => { + return Err(VmError::HostError( + "SQLite parameters support only null, int, float, string, and bytes" + .to_string(), + )); + } + }; + if bytes > limits.max_parameter_bytes { + return Err(VmError::HostError(format!( + "SQLite parameters exceed the configured {} byte limit", + limits.max_parameter_bytes + ))); + } + params.push(sql_value); + } + Ok(params) +} + +fn cancellation_error(token: &CancellationToken) -> VmError { + let reason = token + .reason() + .unwrap_or(CancellationReason::Requested) + .as_str(); + VmError::HostError(format!("SQLite operation cancelled ({reason})")) +} + +fn with_connection( + slot: &ConnectionSlot, + token: &CancellationToken, + operation: impl FnOnce(&mut Connection) -> Result, +) -> VmResult { + token.check().map_err(runtime_error)?; + let mut connection = slot + .connection + .lock() + .map_err(|_| VmError::HostError("SQLite connection lock is poisoned".to_string()))?; + token.check().map_err(runtime_error)?; + let callback_token = token.clone(); + connection.progress_handler( + SQLITE_PROGRESS_STEPS, + Some(move || callback_token.is_cancelled()), + ); + let result = operation(&mut connection); + connection.progress_handler(0, None:: bool>); + if token.is_cancelled() { + return Err(cancellation_error(token)); + } + result.map_err(sqlite_error) +} + +fn sqlite_error(error: rusqlite::Error) -> VmError { + let code = error + .sqlite_error() + .map(|value| value.extended_code.to_string()) + .unwrap_or_else(|| "non_sqlite".to_string()); + let name = error + .sqlite_error_code() + .map(|value| format!("{value:?}")) + .unwrap_or_else(|| "RusqliteError".to_string()); + VmError::HostError(format!("SQLite error {name} ({code}): {error}")) +} + +fn estimate_value_bytes(value: &Value) -> usize { + match value { + Value::Null => 1, + Value::Int(_) | Value::Float(_) => 8, + Value::Bool(_) => 1, + Value::String(value) => value.len(), + Value::Bytes(value) => value.len(), + Value::Array(values) => values.iter().map(estimate_value_bytes).sum(), + Value::Map(values) => values + .iter() + .map(|(key, value)| { + estimate_value_bytes(key).saturating_add(estimate_value_bytes(value)) + }) + .sum(), + Value::Callable(_) => 8, + } +} + +fn value_from_row(row: &rusqlite::Row<'_>, index: usize) -> Result { + match row.get_ref(index)? { + ValueRef::Null => Ok(Value::Null), + ValueRef::Integer(value) => Ok(Value::Int(value)), + ValueRef::Real(value) => Ok(Value::Float(value)), + ValueRef::Text(value) => match std::str::from_utf8(value) { + Ok(value) => Ok(Value::string(value)), + Err(_) => Ok(Value::bytes(value.to_vec())), + }, + ValueRef::Blob(value) => Ok(Value::bytes(value.to_vec())), + } +} + +fn query_with_connection( + connection: &Connection, + sql: &str, + params: &[SqlValue], + limits: SqliteLimits, +) -> Result { + let mut statement = connection.prepare(sql)?; + let columns = statement + .column_names() + .into_iter() + .map(Value::string) + .collect::>(); + if columns.len() > limits.max_columns { + return Err(rusqlite::Error::InvalidColumnIndex(columns.len())); + } + let column_count = columns.len(); + let mut rows = statement.query(params_from_iter(params.iter()))?; + let mut values = Vec::new(); + let mut result_bytes = columns.iter().map(estimate_value_bytes).sum::(); + let mut truncated = false; + let mut next_cursor = None; + while let Some(row) = rows.next()? { + if values.len() >= limits.max_rows { + truncated = true; + break; + } + let mut cells = Vec::with_capacity(column_count); + let mut row_bytes = 0usize; + for index in 0..column_count { + let value = value_from_row(row, index)?; + row_bytes = row_bytes.saturating_add(estimate_value_bytes(&value)); + cells.push(value); + } + if result_bytes.saturating_add(row_bytes) > limits.max_result_bytes { + truncated = true; + break; + } + if let Some(Value::Int(cursor)) = cells.first() { + next_cursor = Some(*cursor); + } + result_bytes = result_bytes.saturating_add(row_bytes); + values.push(Value::array(cells)); + } + let mut entries = vec![ + (Value::string("columns"), Value::array(columns)), + (Value::string("rows"), Value::array(values)), + (Value::string("truncated"), Value::Bool(truncated)), + ]; + if let Some(next_cursor) = next_cursor { + entries.push((Value::string("next_cursor"), Value::Int(next_cursor))); + } + Ok(VmMap::from_entries(entries)) +} + +fn execute_with_connection( + connection: &Connection, + sql: &str, + params: &[SqlValue], +) -> Result { + let mut statement = connection.prepare(sql)?; + let rows_affected = statement.execute(params_from_iter(params.iter()))?; + drop(statement); + Ok(VmMap::from_entries(vec![ + ( + Value::string("rows_affected"), + Value::Int(i64::try_from(rows_affected).unwrap_or(i64::MAX)), + ), + ( + Value::string("last_insert_rowid"), + Value::Int(connection.last_insert_rowid()), + ), + ])) +} + +fn pending_count_for_resource(vm: &Vm, resource: ResourceHandle) -> usize { + vm.host + .runtime_operations + .operations_for_resource(resource) + .into_iter() + .filter(|operation| operation.owner() == OperationOwner::Sqlite) + .count() +} + +fn schedule_operation( + vm: &mut Vm, + resource: ResourceHandle, + slot: Arc, + operation: impl FnOnce(Arc, CancellationToken) -> VmResult + + Send + + 'static, +) -> VmResult { + if pending_count_for_resource(vm, resource) >= slot.limits.max_pending_operations { + return Err(VmError::HostError(format!( + "SQLite pending operation limit {} reached", + slot.limits.max_pending_operations + ))); + } + let deadline = + Instant::now().checked_add(Duration::from_millis(slot.limits.max_transaction_ms)); + let operation_state = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Sqlite, + Some(&vm.run_ctx.cancellation), + deadline, + None, + ) + .map_err(runtime_error)?; + let id = operation_state.id(); + let token = operation_state.token(); + let cleanup_slot = Arc::clone(&slot); + operation_state + .set_cleanup(Box::new(move |end| { + if matches!(end, super::cancellation::OperationEnd::Cancelled(_)) + && cleanup_slot + .active_operation + .lock() + .expect("SQLite active operation lock should not be poisoned") + .is_some_and(|active| active == id) + { + cleanup_slot.interrupt.interrupt(); + } + Ok(()) + })) + .map_err(runtime_error)?; + let worker_operation = operation_state.clone(); + let (sender, receiver) = mpsc::channel(); + let waker = Arc::new(Mutex::new(None::)); + let worker_waker = Arc::clone(&waker); + let worker = thread::Builder::new() + .name(format!("rustscript-sqlite-{}", id.raw())) + .spawn(move || { + let _execution = slot + .execution + .lock() + .expect("SQLite execution lock should not be poisoned"); + *slot + .active_operation + .lock() + .expect("SQLite active operation lock should not be poisoned") = Some(id); + let result = operation(Arc::clone(&slot), token); + *slot + .active_operation + .lock() + .expect("SQLite active operation lock should not be poisoned") = None; + match &result { + Ok(_) => { + let _ = worker_operation.complete(); + } + Err(error) => { + let _ = worker_operation.fail( + RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "sqlite::operation", + error.to_string(), + ) + .with_value(id.raw()), + ); + } + } + let _ = sender.send(result); + if let Ok(mut waker) = worker_waker.lock() + && let Some(waker) = waker.take() + { + waker.wake(); + } + }) + .map_err(|error| { + let _ = vm + .host + .runtime_operations + .cancel(id, CancellationReason::Requested); + VmError::HostError(format!("failed to start SQLite worker: {error}")) + })?; + let pending = PendingResult { + receiver, + worker: Some(worker), + waker, + }; + let payload = match vm.host.runtime_resources.insert_with_cleanup( + ResourceTypeId::CALLBACK, + pending, + |pending, _reason| { + wait_worker_bounded(pending); + Ok(()) + }, + ) { + Ok(payload) => payload, + Err(error) => { + let _ = vm + .host + .runtime_operations + .cancel(id, CancellationReason::ResourceClosed); + return Err(runtime_error(error)); + } + }; + operation_state.set_resource(resource); + operation_state.set_payload(payload); + Ok(id.raw()) +} + +fn wait_worker_bounded(mut pending: PendingResult) { + let deadline = Instant::now() + SQLITE_CLOSE_GRACE; + if let Some(worker) = pending.worker.take() { + while !worker.is_finished() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(1)); + } + if worker.is_finished() { + let _ = worker.join(); + } + } +} + +#[cfg(test)] +#[allow(dead_code)] +pub(super) fn active_operation_id(vm: &Vm, resource_id: i64) -> Option { + let handle = ResourceHandle::from_value(&Value::Int(resource_id)).ok()?; + let slot = vm + .host + .runtime_resources + .get::>(handle, ResourceTypeId::SQLITE_CONNECTION) + .ok()?; + let active = *slot + .active_operation + .lock() + .expect("SQLite active operation lock should not be poisoned"); + active.map(OperationId::raw) +} + +fn cancel_operation(vm: &mut Vm, id: OperationId, reason: CancellationReason) { + let Ok(operation) = vm.host.runtime_operations.get(id) else { + return; + }; + if operation.owner() != OperationOwner::Sqlite { + return; + } + super::cancel_runtime_operation(vm, id, reason); +} + +pub(super) fn poll_pending_op( + vm: &mut Vm, + op_id: HostOpId, + cx: &mut Context<'_>, +) -> Poll> { + let id = match operation_id(op_id) { + Ok(id) => id, + Err(error) => return Poll::Ready(Err(error)), + }; + let operation = match vm.host.runtime_operations.get(id) { + Ok(operation) if operation.owner() == OperationOwner::Sqlite => operation, + Ok(_) => { + return Poll::Ready(Err(VmError::HostError(format!( + "host operation {op_id} is not owned by SQLite" + )))); + } + Err(error) => return Poll::Ready(Err(runtime_error(error))), + }; + let Some(payload) = operation.payload() else { + return Poll::Ready(Err(VmError::HostError(format!( + "SQLite operation {op_id} has no completion payload" + )))); + }; + if operation.token().is_cancelled() { + let reason = operation + .token() + .reason() + .unwrap_or(CancellationReason::Requested); + let error = cancellation_error(&operation.token()); + cancel_operation(vm, id, reason); + return Poll::Ready(Err(error)); + } + let (received, worker) = { + let pending = match vm + .host + .runtime_resources + .get_mut::(payload, ResourceTypeId::CALLBACK) + { + Ok(pending) => pending, + Err(error) => return Poll::Ready(Err(runtime_error(error))), + }; + if let Ok(mut waker) = pending.waker.lock() { + *waker = Some(cx.waker().clone()); + } + let received = pending.receiver.try_recv(); + let worker = if matches!(received, Err(mpsc::TryRecvError::Empty)) { + None + } else { + pending.worker.take() + }; + (received, worker) + }; + if let Some(worker) = worker { + let _ = worker.join(); + } + match received { + Err(mpsc::TryRecvError::Empty) => { + if operation.token().is_cancelled() { + let reason = operation + .token() + .reason() + .unwrap_or(CancellationReason::Requested); + let error = cancellation_error(&operation.token()); + cancel_operation(vm, id, reason); + Poll::Ready(Err(error)) + } else { + Poll::Pending + } + } + Err(mpsc::TryRecvError::Disconnected) => { + let _ = super::close_runtime_resource(vm, payload, CancellationReason::ResourceClosed); + Poll::Ready(Err(VmError::HostError( + "SQLite worker ended without a result".to_string(), + ))) + } + Ok(result) => { + let _ = super::close_runtime_resource(vm, payload, CancellationReason::ResourceClosed); + match result { + Ok(value) => { + if let OperationStatus::Cancelled(_) = operation.status() { + Poll::Ready(Err(cancellation_error(&operation.token()))) + } else { + Poll::Ready(Ok(value)) + } + } + Err(error) => { + if operation.token().is_cancelled() { + Poll::Ready(Err(cancellation_error(&operation.token()))) + } else { + Poll::Ready(Err(error)) + } + } + } + } + } +} + +/// Opens a SQLite database under the embedding-owned path and limit policy. +#[pd_host_function(name = "sqlite::open")] +pub(super) fn builtin_sqlite_open_impl(vm: &mut Vm, options: VmMapRef<'_>) -> VmResult { + let options = parse_open_options(vm, options)?; + let open_count = vm + .host + .runtime_resources + .count_type(ResourceTypeId::SQLITE_CONNECTION); + if open_count >= options.limits.max_connections { + return Err(VmError::HostError(format!( + "SQLite connection limit {} reached", + options.limits.max_connections + ))); + } + let connection = open_connection(&options)?; + let interrupt = Arc::new(connection.get_interrupt_handle()); + let slot = Arc::new(ConnectionSlot { + connection: Mutex::new(connection), + execution: Mutex::new(()), + active_operation: Mutex::new(None), + interrupt: Arc::clone(&interrupt), + limits: options.limits, + allow_unsafe_sql: options.allow_unsafe_sql, + }); + let cleanup_interrupt = Arc::clone(&interrupt); + let handle = vm + .host + .runtime_resources + .insert_with_cleanup( + ResourceTypeId::SQLITE_CONNECTION, + slot, + move |_slot, _reason| { + cleanup_interrupt.interrupt(); + Ok(()) + }, + ) + .map_err(runtime_error)?; + Ok(handle_value(handle)) +} + +/// Executes one parameterized SQLite statement asynchronously. +#[pd_host_function(name = "sqlite::execute")] +pub(super) fn builtin_sqlite_execute_impl( + vm: &mut Vm, + db_id: i64, + sql: &str, + params: VmArrayRef<'_>, +) -> VmResult> { + let (resource, slot) = lookup_connection(vm, db_id)?; + validate_sql(sql, slot.limits, slot.allow_unsafe_sql)?; + let sql = sql.to_string(); + let params = sqlite_params(params, slot.limits)?; + let op_id = schedule_operation(vm, resource, slot, move |slot, token| { + with_connection(&slot, &token, |connection| { + execute_with_connection(connection, &sql, ¶ms) + }) + .map(|value| CallReturn::one(Value::Map(Arc::new(value)))) + })?; + Ok(HostCallResult::Pending(op_id)) +} + +/// Runs one parameterized SQLite query with row and result-byte bounds. +#[pd_host_function(name = "sqlite::query")] +pub(super) fn builtin_sqlite_query_impl( + vm: &mut Vm, + db_id: i64, + sql: &str, + params: VmArrayRef<'_>, + limits: VmMapRef<'_>, +) -> VmResult> { + let (resource, slot) = lookup_connection(vm, db_id)?; + let query_limits = parse_query_limits(limits, slot.limits)?; + validate_sql(sql, query_limits, slot.allow_unsafe_sql)?; + let sql = sql.to_string(); + let params = sqlite_params(params, slot.limits)?; + let op_id = schedule_operation(vm, resource, slot, move |slot, token| { + with_connection(&slot, &token, |connection| { + query_with_connection(connection, &sql, ¶ms, query_limits) + }) + .map(|value| CallReturn::one(Value::Map(Arc::new(value)))) + })?; + Ok(HostCallResult::Pending(op_id)) +} + +struct TransactionStatement { + sql: String, + params: Vec, + query: bool, + limits: SqliteLimits, +} + +fn parse_transaction_statements( + statements: VmArrayRef<'_>, + limits: SqliteLimits, + allow_unsafe_sql: bool, +) -> VmResult> { + if statements.is_empty() { + return Err(VmError::HostError( + "SQLite transaction requires at least one statement".to_string(), + )); + } + if statements.len() > limits.max_statements { + return Err(VmError::HostError(format!( + "SQLite transaction exceeds the configured {} statement limit", + limits.max_statements + ))); + } + statements + .iter() + .map(|statement| { + let Value::Map(statement) = statement else { + return Err(VmError::TypeMismatch("SQLite transaction statement map")); + }; + let sql = required_string(statement, "sql")?; + validate_sql(&sql, limits, allow_unsafe_sql)?; + let params = match map_value(statement, "params") { + Some(Value::Array(params)) => sqlite_params(params, limits)?, + Some(_) => return Err(VmError::TypeMismatch("SQLite parameter array")), + None => Vec::new(), + }; + let query = match map_value(statement, "query") { + Some(Value::Bool(query)) => *query, + Some(_) => return Err(VmError::TypeMismatch("SQLite query flag")), + None => false, + }; + let statement_limits = match map_value(statement, "limits") { + Some(Value::Map(statement_limits)) => parse_query_limits(statement_limits, limits)?, + Some(_) => return Err(VmError::TypeMismatch("SQLite limits map")), + None => limits, + }; + Ok(TransactionStatement { + sql, + params, + query, + limits: statement_limits, + }) + }) + .collect() +} + +/// Executes ordered statements atomically and returns ordered result envelopes. +#[pd_host_function(name = "sqlite::transaction")] +pub(super) fn builtin_sqlite_transaction_impl( + vm: &mut Vm, + db_id: i64, + statements: VmArrayRef<'_>, +) -> VmResult>> { + let (resource, slot) = lookup_connection(vm, db_id)?; + let statements = parse_transaction_statements(statements, slot.limits, slot.allow_unsafe_sql)?; + let op_id = schedule_operation(vm, resource, slot, move |slot, token| { + with_connection(&slot, &token, |connection| { + let transaction = + connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let mut results = Vec::with_capacity(statements.len()); + for statement in statements { + let value = if statement.query { + query_with_connection( + &transaction, + &statement.sql, + &statement.params, + statement.limits, + )? + } else { + execute_with_connection(&transaction, &statement.sql, &statement.params)? + }; + results.push(Value::Map(Arc::new(value))); + } + transaction.commit()?; + Ok(results) + }) + .map(|values| CallReturn::one(Value::array(values))) + })?; + Ok(HostCallResult::Pending(op_id)) +} + +/// Closes a SQLite resource and cancels operations using it. +#[pd_host_function(name = "sqlite::close")] +pub(super) fn builtin_sqlite_close_impl(vm: &mut Vm, db_id: i64) -> VmResult<()> { + let handle = sqlite_handle(db_id)?; + super::close_runtime_resource(vm, handle, CancellationReason::ResourceClosed) + .map_err(|error| VmError::HostError(format!("unknown SQLite database: {error}")))?; + Ok(()) +} diff --git a/src/builtins/runtime/typed.rs b/src/builtins/runtime/typed.rs index 55612e0e..9162807a 100644 --- a/src/builtins/runtime/typed.rs +++ b/src/builtins/runtime/typed.rs @@ -130,6 +130,15 @@ impl<'a> FromVmValue<'a> for &'a str { } } +impl FromVmValue<'_> for String { + fn from_vm_value(value: &Value, _label: &str) -> VmResult { + match value { + Value::String(text) => Ok(text.to_string()), + _ => Err(VmError::TypeMismatch("string")), + } + } +} + impl<'a> FromVmValue<'a> for &'a [u8] { fn from_vm_value(value: &'a Value, _label: &str) -> VmResult { match value { @@ -157,6 +166,15 @@ impl<'a> FromVmValue<'a> for &'a VmMap { } } +impl FromVmValue<'_> for VmMap { + fn from_vm_value(value: &Value, _label: &str) -> VmResult { + match value { + Value::Map(entries) => Ok(entries.as_ref().clone()), + _ => Err(VmError::TypeMismatch("map")), + } + } +} + impl FromVmValue<'_> for SharedArray { fn from_vm_value(value: &Value, _label: &str) -> VmResult { match value { @@ -453,13 +471,24 @@ where } } +impl IntoBuiltinCallOutcome for CallOutcome { + fn into_builtin_call_outcome(self) -> BuiltinCallOutcome { + match self { + CallOutcome::Return(values) => BuiltinCallOutcome::Return(values), + CallOutcome::Halt => BuiltinCallOutcome::Halt, + CallOutcome::Pending(op_id) => BuiltinCallOutcome::Pending(op_id), + CallOutcome::Yield => unreachable!("async builtin wrappers cannot return Yield"), + } + } +} + impl IntoBuiltinCallOutcome for HostCallResult where T: IntoVmValue, { fn into_builtin_call_outcome(self) -> BuiltinCallOutcome { match self { - Self::Return(value) => value.into_builtin_call_outcome(), + Self::Return(value) => BuiltinCallOutcome::Return(return_one(value)), Self::Pending(op_id) => BuiltinCallOutcome::Pending(op_id), } } diff --git a/src/bytecode.rs b/src/bytecode.rs index 23d12a71..8ee97807 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -7,8 +7,9 @@ use crate::compiler::TypeSchema; /// Bytecode ABI version used for VM-internal cache identity (JIT trace cache, /// program cache keys). The VMBC wire format version lives in `src/vmbc.rs` -/// (`VERSION_V11`); both were bumped together for the static builtin ID break. -pub const BYTECODE_ABI_VERSION: u16 = 11; +/// (`VERSION_V12`); both were bumped together for the static builtin ID break +/// and again for the direct script-call (`CallScript`) opcode break. +pub const BYTECODE_ABI_VERSION: u16 = 12; pub type SharedString = Arc; pub type SharedBytes = Arc>; @@ -80,6 +81,7 @@ pub struct ExportedCallable { #[derive(Debug)] pub struct CallableEnvironment { + #[cfg_attr(not(feature = "runtime"), allow(dead_code))] pub(crate) cells: std::sync::Mutex>, } @@ -813,6 +815,12 @@ pub enum OpCode { Dup = 0x0E, Ldloc = 0x0F, Stloc = 0x10, + /// Static builtin/host call. Operands: `import:u16` little-endian then + /// `argc:u8` (3 operand bytes). The `u16` operand is an explicit static + /// builtin call index from the catalog (or a host-import slot), never a + /// count-derived offset. Consumes `argc` arguments from the stack; the + /// callee is owned by the builtin catalog, so no callable value exists + /// in the frame. Call = 0x11, Shl = 0x12, Shr = 0x13, @@ -821,7 +829,18 @@ pub enum OpCode { Or = 0x16, Not = 0x17, Lshr = 0x18, + /// Dynamic callable-value call. Operand: `argc:u8` (1 operand byte). + /// Consumes a stack segment in `callee, arg0, ..., argN` order: the + /// callable value (including its environment, if any) is owned by the + /// caller operand stack at the call site and remains the caller's + /// responsibility. CallValue = 0x19, + /// Static script-function call by prototype id. Operands: `prototype_id: + /// u32` little-endian then `argc: u8` (5 operand bytes). The callee is + /// resolved through callable prototype metadata; no callable value is + /// consumed from the stack, so environment-free named functions can be + /// called without a hidden callable local. + CallScript = 0x1A, } impl TryFrom for OpCode { @@ -855,6 +874,7 @@ impl TryFrom for OpCode { x if x == Self::Not as u8 => Ok(Self::Not), x if x == Self::Lshr as u8 => Ok(Self::Lshr), x if x == Self::CallValue as u8 => Ok(Self::CallValue), + x if x == Self::CallScript as u8 => Ok(Self::CallScript), _ => Err(()), } } @@ -885,6 +905,7 @@ impl OpCode { Self::Ldc | Self::Br | Self::Brfalse => 4, Self::Ldloc | Self::Stloc | Self::CallValue => 1, Self::Call => 3, + Self::CallScript => 5, } } @@ -916,6 +937,7 @@ impl OpCode { OpCode::Not => "not", OpCode::Lshr => "lshr", Self::CallValue => "callvalue", + Self::CallScript => "callscript", } } @@ -947,6 +969,7 @@ impl OpCode { "not" => Some(OpCode::Not), "lshr" => Some(OpCode::Lshr), "callvalue" => Some(OpCode::CallValue), + "callscript" => Some(OpCode::CallScript), _ => None, } } @@ -1072,4 +1095,20 @@ mod tests { assert_eq!(map.remove(&Value::string("a")), Some(Value::Int(2))); assert_eq!(map.len(), 1); } + + #[test] + fn call_script_opcode_contract() { + // ISA contract: CallScript = 0x1A (immediately after CallValue), + // operands prototype_id:u32 LE + argc:u8, 5 operand bytes total. + assert_eq!(OpCode::CallScript as u8, 0x1A); + assert_eq!(OpCode::CallScript as u8, OpCode::CallValue as u8 + 1); + assert_eq!(OpCode::CallScript.operand_len(), 5); + assert_eq!(OpCode::CallScript.mnemonic(), "callscript"); + assert_eq!( + OpCode::parse_mnemonic("callscript"), + Some(OpCode::CallScript) + ); + assert_eq!(OpCode::try_from(0x1A), Ok(OpCode::CallScript)); + assert_eq!(OpCode::CallScript as u8, 0x1A); + } } diff --git a/src/cli.rs b/src/cli.rs index a02f99c1..45684d7f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -332,16 +332,51 @@ fn run_vm_loop( fn render_source_path_error(source_path: &Path, err: &SourcePathError) -> String { match err { - SourcePathError::Source(vm::SourceError::Parse(parse)) => { - let source = std::fs::read_to_string(source_path).unwrap_or_default(); + SourcePathError::SourceWithMap { .. } => { + vm::render_source_path_error(source_path, err, true) + } + SourcePathError::Source(error) => render_source_error_at_path(source_path, None, error), + SourcePathError::InvalidImportSyntax { + path, + line, + message, + } => { + let source = std::fs::read_to_string(path).unwrap_or_default(); let mut source_map = SourceMap::new(); - let source_id = source_map.add_source(source_path.display().to_string(), source); + let source_id = source_map.add_source(path.display().to_string(), source); + let parse = vm::ParseError::at_line(*line, message.clone()) + .with_line_span_from_source(&source_map, source_id); + render_source_error(&source_map, &parse, true) + } + _ => err.to_string(), + } +} + +fn render_source_error_at_path( + source_path: &Path, + source_override: Option<&str>, + error: &vm::SourceError, +) -> String { + match error { + vm::SourceError::Parse(parse) => { + let render_path = parse + .message + .split_once(": ") + .map(|(path, _)| Path::new(path)) + .filter(|path| path.exists()) + .unwrap_or(source_path); + let source = source_override + .filter(|_| render_path == source_path) + .map(str::to_owned) + .unwrap_or_else(|| std::fs::read_to_string(render_path).unwrap_or_default()); + let mut source_map = SourceMap::new(); + let source_id = source_map.add_source(render_path.display().to_string(), source); let parse = parse .clone() .with_line_span_from_source(&source_map, source_id); render_source_error(&source_map, &parse, true) } - SourcePathError::Source(vm::SourceError::Compile(compile)) => { + vm::SourceError::Compile(compile) => { let render_path = compile .source_name() .map(Path::new) @@ -352,19 +387,6 @@ fn render_source_path_error(source_path: &Path, err: &SourcePathError) -> String source_map.add_source(render_path.display().to_string(), source); vm::render_compile_error(&source_map, compile, true) } - SourcePathError::InvalidImportSyntax { - path, - line, - message, - } => { - let source = std::fs::read_to_string(path).unwrap_or_default(); - let mut source_map = SourceMap::new(); - let source_id = source_map.add_source(path.display().to_string(), source); - let parse = vm::ParseError::at_line(*line, message.clone()) - .with_line_span_from_source(&source_map, source_id); - render_source_error(&source_map, &parse, true) - } - _ => err.to_string(), } } @@ -2372,4 +2394,117 @@ mod tests { fn repl_input_incomplete_for_trailing_operator() { assert!(!super::is_repl_input_complete("let a = 1 +")); } + + fn cli_diagnostic_root(prefix: &str) -> std::path::PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("cli diagnostic root should be created"); + root.canonicalize().unwrap_or(root) + } + + #[test] + fn cli_nested_module_parse_error_renders_nested_source_frame() { + let root = cli_diagnostic_root("pd-vm-cli-nested-parse-diag"); + let main_path = root.join("main.rss"); + let nested_path = root.join("nested.rss"); + std::fs::write(&main_path, "use self::nested as nested;\nnested::run();\n") + .expect("main fixture should write"); + std::fs::write(&nested_path, "pub fn run( {\n").expect("nested fixture should write"); + + let error = match vm::compile_source_file_with_options( + &main_path, + vm::CompileSourceFileOptions::default(), + ) { + Ok(_) => panic!("nested parse error fixture should fail"), + Err(error) => error, + }; + let rendered = super::render_source_path_error(&main_path, &error); + + // The rendered frame must belong to the nested source: its path, its + // line text, and an underline, not the root file. + assert!( + rendered.contains(&nested_path.display().to_string()), + "rendered diagnostic should name the nested path: {rendered}" + ); + assert!( + rendered.contains("pub fn run( {"), + "rendered diagnostic should show the nested source line: {rendered}" + ); + assert!( + rendered.contains('^'), + "rendered diagnostic should underline the nested source: {rendered}" + ); + assert!( + !rendered.contains("use self::nested as nested;"), + "rendered diagnostic should not show the root source frame: {rendered}" + ); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn cli_nested_strict_type_error_renders_nested_source_frame() { + let root = cli_diagnostic_root("pd-vm-cli-nested-strict-diag"); + let main_path = root.join("main.rss"); + let nested_path = root.join("nested.rss"); + std::fs::write(&main_path, "use self::nested as nested;\nnested::run();\n") + .expect("main fixture should write"); + std::fs::write(&nested_path, "pub fn run() -> unknown { 1 }\n") + .expect("nested fixture should write"); + + let error = match vm::compile_source_file_with_options( + &main_path, + vm::CompileSourceFileOptions::default(), + ) { + Ok(_) => panic!("strict nested fixture should fail"), + Err(error) => error, + }; + let rendered = super::render_source_path_error(&main_path, &error); + + assert!( + rendered.contains(&nested_path.display().to_string()), + "rendered diagnostic should name the nested path: {rendered}" + ); + assert!( + rendered.contains("pub fn run() -> unknown { 1 }"), + "rendered diagnostic should show the nested source line: {rendered}" + ); + assert!( + rendered.contains('^'), + "rendered diagnostic should underline the nested source: {rendered}" + ); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn render_source_error_uses_source_override_for_virtual_paths() { + let virtual_path = std::path::Path::new("__pd_vm_inmemory__/main.rss"); + let error = vm::SourceError::Parse(vm::ParseError::at_line(2, "boom")); + let rendered = super::render_source_error_at_path( + virtual_path, + Some("line one\nline two target\nline three"), + &error, + ); + + assert!( + rendered.contains("__pd_vm_inmemory__/main.rss"), + "rendered diagnostic should name the virtual path: {rendered}" + ); + assert!( + rendered.contains("line two target"), + "rendered diagnostic should show the override source line: {rendered}" + ); + assert!( + rendered.contains('^'), + "rendered diagnostic should underline the override source: {rendered}" + ); + } } diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index 5b9b43f3..84f3821c 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -11,6 +11,7 @@ use super::ir::{ ClosureExpr, Expr, FunctionDecl, FunctionImpl, LocalSlot, MatchPattern, MatchTypePattern, Stmt, StructDecl, TypeSchema, }; +use super::materialization::CallableUseFacts; use super::{CompileError, TypingMode, typing}; pub struct Compiler { @@ -33,7 +34,21 @@ pub struct Compiler { frame_local_count: usize, function_slots: HashMap, specialized_function_slots: Vec<(u16, Vec, LocalSlot)>, + /// Prototype-only specializations for direct generic calls: the same + /// function target as the base prototype but carrying the instantiated + /// concrete schema. Unlike [`Self::specialized_function_slots`] these + /// allocate no hidden local or root binding, so direct-only generic + /// calls stay slot-free. + specialized_direct_prototypes: Vec<(u16, Vec, u32)>, function_prototype_ids: HashMap, + /// Semantic use classification for every named script function, keyed + /// by resolved flat function index, delivered by the pipeline. Codegen + /// consumes `requires_callable_slot` when counting callable slots and + /// assigning hidden callable locals, so direct-only functions are + /// lowered by `CallScript` with no hidden slot. Direct `Compiler` users + /// (the public API) provide no facts; absent facts conservatively mean + /// full materialization (legacy behavior). + callable_use_facts: HashMap, script_functions: Vec, callable_prototypes: Vec, function_regions: Vec, @@ -82,7 +97,9 @@ impl Compiler { frame_local_count: 0, function_slots: HashMap::new(), specialized_function_slots: Vec::new(), + specialized_direct_prototypes: Vec::new(), function_prototype_ids: HashMap::new(), + callable_use_facts: HashMap::new(), script_functions: Vec::new(), callable_prototypes: Vec::new(), function_regions: Vec::new(), @@ -130,6 +147,13 @@ impl Compiler { self.function_decls = function_decls; } + pub(crate) fn set_callable_use_facts( + &mut self, + callable_use_facts: HashMap, + ) { + self.callable_use_facts = callable_use_facts; + } + pub fn set_struct_schemas(&mut self, struct_schemas: HashMap) { self.struct_schemas = struct_schemas; } @@ -248,17 +272,53 @@ impl Compiler { fn prepare_named_callables(&mut self) -> Result, CompileError> { let mut indices = self.function_impls.keys().copied().collect::>(); indices.sort_unstable(); - self.frame_local_count = self - .root_local_count - .checked_add(indices.len()) - .ok_or(CompileError::LocalSlotOverflow(LocalSlot::MAX))?; - if self.frame_local_count > usize::from(u8::MAX) + 1 { - return Err(CompileError::LocalSlotOverflow(LocalSlot::MAX)); + // Classification facts may be absent for direct `Compiler` users + // (the public API); the conservative default is full + // materialization, which is exactly the allocation performed below + // when no facts are present. The pipeline-delivered facts refine + // this decision: a function that only needs a prototype (direct + // calls, including non-capturing direct recursion) is lowered by + // `CallScript` and gets no hidden callable slot. + // + // Report the real aggregate before mutating callable metadata: data + // slots (compacted root frame) plus one hidden callable slot per + // materialized named function. A saturated add reports the + // saturated total rather than a fabricated slot number. + let data_slots = self.root_local_count; + let callable_slots = indices + .iter() + .filter(|index| { + self.callable_use_facts + .get(index) + .is_none_or(|facts| facts.requires_callable_slot()) + }) + .count(); + let total_slots = data_slots.saturating_add(callable_slots); + let max_slots = usize::from(u8::MAX) + 1; + if total_slots > max_slots { + return Err(CompileError::FrameLocalLimitExceeded { + data_slots, + callable_slots, + total_slots, + max_slots, + }); } + self.frame_local_count = total_slots; + let mut materialized_position = 0usize; for (position, function_index) in indices.iter().copied().enumerate() { - let hidden_slot = LocalSlot::try_from(self.root_local_count + position) - .map_err(|_| CompileError::LocalSlotOverflow(LocalSlot::MAX))?; + let requires_slot = self + .callable_use_facts + .get(&function_index) + .is_none_or(|facts| facts.requires_callable_slot()); + let hidden_slot = if requires_slot { + let slot = LocalSlot::try_from(self.root_local_count + materialized_position) + .map_err(|_| CompileError::LocalSlotOverflow(LocalSlot::MAX))?; + materialized_position += 1; + Some(slot) + } else { + None + }; let prototype_id = self.callable_prototypes.len() as u32; let script_function_id = self.script_functions.len() as u32 + position as u32; let function_impl = self @@ -266,7 +326,9 @@ impl Compiler { .get(&function_index) .expect("function index came from implementation map"); let decl = self.function_decls.get(&function_index); - self.function_slots.insert(function_index, hidden_slot); + if let Some(hidden_slot) = hidden_slot { + self.function_slots.insert(function_index, hidden_slot); + } self.function_prototype_ids .insert(function_index, prototype_id); self.callable_prototypes.push(CallablePrototype { @@ -296,7 +358,7 @@ impl Compiler { super::lifetime::function_capture_binding_mode(function_impl, *target) }) .collect(), - self_slot: Some(hidden_slot), + self_slot: hidden_slot, schema: decl.map(|decl| TypeSchema::Callable { params: decl .arg_schemas @@ -306,7 +368,9 @@ impl Compiler { result: Box::new(decl.return_schema.clone().unwrap_or(TypeSchema::Unknown)), }), }); - if function_impl.capture_copies.is_empty() { + if function_impl.capture_copies.is_empty() + && let Some(hidden_slot) = hidden_slot + { self.root_callable_bindings.push(RootCallableBinding { local_slot: hidden_slot, prototype_id, @@ -668,8 +732,16 @@ impl Compiler { let slot = self.ensure_function_value_slot(*index, type_args)?; self.emit_copy_ldloc(slot)?; } - Expr::Call(index, _, args) => { - self.compile_function_call(*index, args)?; + // Resolved module targets are lowered into plain flat-index calls + // by `linker::merge_units`; reaching codegen means the merge + // missed a site. + Expr::ModuleFunctionRef(..) + | Expr::ModuleCall(..) + | Expr::UnresolvedFunctionRef { .. } => { + return Err(CompileError::UnresolvedModuleCall); + } + Expr::Call(index, type_args, args) => { + self.compile_function_call(*index, type_args, args)?; } Expr::Closure(closure) => { let _ = self.emit_closure_callable(closure)?; @@ -1322,6 +1394,17 @@ impl Compiler { let (target_index, arity) = if let Some(builtin) = BuiltinFunction::from_call_index(index) { (index, builtin.arity()) } else if let Some(decl) = self.function_decls.get(&index) { + if self.function_impls.contains_key(&index) { + // A script-function implementation reached the value domain + // without a materialized `function_slots` entry (a + // callable-use classifier miss on a direct-only function). + // Never synthesize a HostImport prototype for a script + // implementation: the frame-local budget and the script + // prototype were already fixed by + // `prepare_named_callables`, so a late slot allocation + // would silently corrupt the callable metadata. + return Err(CompileError::CallableUsedAsValue); + } ( self.call_index_remap.get(&index).copied().unwrap_or(index), decl.args.len() as u8, @@ -1356,6 +1439,40 @@ impl Compiler { } } + /// Resolve (or create) the prototype-only specialization for a direct + /// generic call: the same script-function target as the base prototype + /// but carrying the instantiated concrete schema. No hidden local or + /// root binding is allocated, so direct-only generic calls stay + /// slot-free. Falls back to the base prototype when the instantiated + /// schema is unavailable. + fn ensure_direct_specialized_prototype( + &mut self, + index: u16, + type_args: &[TypeSchema], + ) -> Result { + if let Some((_, _, prototype_id)) = self + .specialized_direct_prototypes + .iter() + .find(|(candidate, args, _)| *candidate == index && args == type_args) + { + return Ok(*prototype_id); + } + let base_prototype_id = *self + .function_prototype_ids + .get(&index) + .ok_or(CompileError::CallableUsedAsValue)?; + let Some(schema) = self.instantiated_callable_schema(index, type_args) else { + return Ok(base_prototype_id); + }; + let mut prototype = self.callable_prototypes[base_prototype_id as usize].clone(); + prototype.schema = Some(schema); + let prototype_id = self.callable_prototypes.len() as u32; + self.callable_prototypes.push(prototype); + self.specialized_direct_prototypes + .push((index, type_args.to_vec(), prototype_id)); + Ok(prototype_id) + } + fn ensure_specialized_function_slot( &mut self, index: u16, @@ -1469,8 +1586,49 @@ impl Compiler { .or_insert(hints); } - fn compile_function_call(&mut self, index: u16, args: &[Expr]) -> Result<(), CompileError> { + fn compile_function_call( + &mut self, + index: u16, + type_args: &[TypeSchema], + args: &[Expr], + ) -> Result<(), CompileError> { if self.function_impls.contains_key(&index) { + let direct_only = self + .callable_use_facts + .get(&index) + .is_some_and(|facts| !facts.requires_callable_slot()); + if direct_only { + // Direct script call: evaluate the arguments and call the + // function's prototype without loading a hidden callable + // local. The prototype was pre-created for every named + // function in `prepare_named_callables`; a direct generic + // call with explicit type arguments resolves the + // specialized prototype carrying the instantiated schema + // so the runtime schema check reflects the call-site + // types instead of the accept-all generic base. + let prototype_id = if type_args.is_empty() { + *self + .function_prototype_ids + .get(&index) + .ok_or(CompileError::CallableUsedAsValue)? + } else { + self.ensure_direct_specialized_prototype(index, type_args)? + }; + let return_type = self + .function_decls + .get(&index) + .map(|decl| decl.return_type) + .unwrap_or(ValueType::Unknown); + for arg in args { + self.compile_scalar_expr(arg)?; + } + let argc = u8::try_from(args.len()).map_err(|_| CompileError::CallArityOverflow)?; + if return_type != ValueType::Unknown { + self.record_operand_types(ValueType::Callable, return_type); + } + self.assembler.call_script(prototype_id, argc); + return Ok(()); + } let slot = *self .function_slots .get(&index) @@ -1999,3 +2157,90 @@ fn eval_const_int_expr(expr: &Expr) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A script function classified as direct-only (script prototype + /// created, no hidden callable slot) reaches `ensure_function_value_slot` + /// through a callable-use classifier miss. The compiler must refuse + /// with a typed `CallableUsedAsValue` error instead of synthesizing a + /// host-import prototype for the script implementation. + #[test] + fn ensure_function_value_slot_rejects_script_impl_without_slot() { + let mut compiler = Compiler::new(); + compiler.function_decls.insert( + 0, + FunctionDecl { + name: "direct_only".to_string(), + arity: 0, + index: 0, + args: Vec::new(), + arg_schemas: Vec::new(), + return_schema: None, + type_params: Vec::new(), + exported: false, + return_type: ValueType::Int, + symbol: None, + }, + ); + compiler.function_impls.insert( + 0, + FunctionImpl { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body_stmts: Vec::new(), + body_expr: Expr::Null, + body_expr_line: 1, + }, + ); + // The pipeline classifier reports direct calls only, so + // `prepare_named_callables` created the script prototype but no + // `function_slots` entry and no root binding. + compiler + .callable_use_facts + .insert(0, CallableUseFacts::default()); + compiler.function_prototype_ids.insert(0, 0); + compiler.callable_prototypes.push(CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 0, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }); + + let prototypes = compiler.callable_prototypes.len(); + let bindings = compiler.root_callable_bindings.len(); + let frame_local_count = compiler.frame_local_count; + + let result = compiler.ensure_function_value_slot(0, &[]); + assert!( + matches!(result, Err(CompileError::CallableUsedAsValue)), + "classifier-miss value use must be a typed compile error, got {result:?}" + ); + assert_eq!( + compiler.callable_prototypes.len(), + prototypes, + "no host-import prototype may be synthesized for a script implementation" + ); + assert_eq!( + compiler.root_callable_bindings.len(), + bindings, + "no root callable binding may be allocated" + ); + assert!( + !compiler.function_slots.contains_key(&0), + "no hidden callable slot may be allocated" + ); + assert_eq!( + compiler.frame_local_count, frame_local_count, + "the frame local count must stay unchanged across the typed rejection" + ); + } +} diff --git a/src/compiler/diagnostics.rs b/src/compiler/diagnostics.rs index f7a27517..832b0bb1 100644 --- a/src/compiler/diagnostics.rs +++ b/src/compiler/diagnostics.rs @@ -1,5 +1,5 @@ use super::source_map::{SourceMap, Span}; -use super::{CompileError, ParseError}; +use super::{CompileError, ParseError, SourceError, SourcePathError}; pub fn render_source_error(source_map: &SourceMap, err: &ParseError, _styled: bool) -> String { let code_prefix = err @@ -19,12 +19,21 @@ pub fn render_source_error(source_map: &SourceMap, err: &ParseError, _styled: bo pub fn render_compile_error(source_map: &SourceMap, err: &CompileError, _styled: bool) -> String { let message = err.diagnostic_message(); - let source_id = err - .source_name() - .and_then(|name| source_map.source_id_by_name(name)) - .unwrap_or(0); + let source_name = err.source_name(); + + // Resolve the owning source id: by name when the error names its source, + // or as the single-file fallback used by inline/REPL compiles (a map with + // exactly one file at id 0). A named error is never rendered against + // another file: when its source is missing from the map it renders as a + // plain path/line message instead of misattributing the span. + let source_id = match source_name { + Some(name) => source_map.source_id_by_name(name), + None if source_map.file(0).is_some() && source_map.file(1).is_none() => Some(0), + None => None, + }; if let Some(line) = err.line() + && let Some(source_id) = source_id && let Some(span) = source_map.line_span(source_id, line) && let Some(rendered) = render_span_snippet(source_map, span, &message) { @@ -32,7 +41,7 @@ pub fn render_compile_error(source_map: &SourceMap, err: &CompileError, _styled: } if let Some(line) = err.line() { - if let Some(source_name) = err.source_name() { + if let Some(source_name) = source_name { return format!("compile error: {source_name}:{line}: {message}"); } return format!("compile error: line {line}: {message}"); @@ -41,6 +50,64 @@ pub fn render_compile_error(source_map: &SourceMap, err: &CompileError, _styled: format!("compile error: {message}") } +/// Render a source error (parse or compile) against the compilation-wide +/// source map carried by a [`SourcePathError`] when present, falling back to +/// a map-less render otherwise. Parse errors whose span references a source +/// id outside the map keep their path-prefixed message. +pub fn render_source_path_error( + source_path: &std::path::Path, + err: &SourcePathError, + _styled: bool, +) -> String { + match err { + SourcePathError::SourceWithMap { error, sources } => match error { + SourceError::Parse(parse) => render_source_error(sources, parse, _styled), + SourceError::Compile(compile) => render_compile_error(sources, compile, _styled), + }, + SourcePathError::Source(error) => match error { + SourceError::Parse(parse) => { + let render_path = parse + .message + .split_once(": ") + .map(|(path, _)| std::path::Path::new(path)) + .filter(|path| path.exists()) + .unwrap_or(source_path); + let source = std::fs::read_to_string(render_path).unwrap_or_default(); + let mut source_map = SourceMap::new(); + let source_id = source_map.add_source(render_path.display().to_string(), source); + let parse = parse + .clone() + .with_line_span_from_source(&source_map, source_id); + render_source_error(&source_map, &parse, _styled) + } + SourceError::Compile(compile) => { + let render_path = compile + .source_name() + .map(std::path::Path::new) + .filter(|path| path.exists()) + .unwrap_or(source_path); + let source = std::fs::read_to_string(render_path).unwrap_or_default(); + let mut source_map = SourceMap::new(); + source_map.add_source(render_path.display().to_string(), source); + render_compile_error(&source_map, compile, _styled) + } + }, + SourcePathError::InvalidImportSyntax { + path, + line, + message, + } => { + let source = std::fs::read_to_string(path).unwrap_or_default(); + let mut source_map = SourceMap::new(); + let source_id = source_map.add_source(path.display().to_string(), source); + let parse = ParseError::at_line(*line, message.clone()) + .with_line_span_from_source(&source_map, source_id); + render_source_error(&source_map, &parse, _styled) + } + _ => err.to_string(), + } +} + fn render_span_snippet(source_map: &SourceMap, span: Span, message: &str) -> Option { let file = source_map.file(span.source_id)?; let (line, col) = file.line_col_for_offset(span.lo)?; diff --git a/src/compiler/frontends/mod.rs b/src/compiler/frontends/mod.rs index 45a0380b..af0ed9c8 100644 --- a/src/compiler/frontends/mod.rs +++ b/src/compiler/frontends/mod.rs @@ -20,11 +20,62 @@ pub(super) fn parse_source( source: &str, flavor: SourceFlavor, options: &CompileSourceFileOptions, +) -> Result { + parse_source_with_source_id(source, flavor, options, 0) +} + +/// Parse `source` and attribute every produced span to `original_source_id`. +/// +/// The id belongs to the compilation-wide [`SourceMap`] built by the source +/// loader, whose ids are the semantic module graph's +/// [`SourceId`](crate::compiler::modules::SourceId) space. Spans produced by +/// this parse (including the error span on failure) therefore stay owned by +/// the module's source after unit merge. The default id `0` preserves the +/// legacy single-source behavior for entry points that build their own map. +pub(super) fn parse_source_with_source_id( + source: &str, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, + original_source_id: u32, +) -> Result { + parse_source_with_source_id_and_externs(source, flavor, options, original_source_id, false) +} + +/// Parse one module's source for the source loader (module mode). +/// +/// Module-mode parses enable the parser's implicit-extern fallback so that +/// calls to imported module functions and module namespace members parse +/// before the loader resolves them by [`SymbolId`](crate::compiler::modules::SymbolId). +/// The produced IR carries the implicit-extern names on +/// [`FrontendIr::implicit_extern_names`] so the loader keeps those synthetic +/// declarations out of module declaration/export tables. +pub(super) fn parse_module_source_with_source_id( + source: &str, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, + original_source_id: u32, +) -> Result { + parse_source_with_source_id_and_externs(source, flavor, options, original_source_id, true) +} + +fn parse_source_with_source_id_and_externs( + source: &str, + flavor: SourceFlavor, + options: &CompileSourceFileOptions, + original_source_id: u32, + allow_implicit_externs: bool, ) -> Result { match flavor { SourceFlavor::RustScript => { let lowered = rustscript::lower(source)?; - parse_lowered_with_mapping(source, lowered, false, false, true) + parse_lowered_with_mapping( + source, + lowered, + allow_implicit_externs, + false, + true, + original_source_id, + ) } SourceFlavor::JavaScript | SourceFlavor::Lua => { let Some(plugin) = options.source_plugin_for_flavor(flavor) else { @@ -60,6 +111,7 @@ pub fn parse_source_with_dialect( options.allow_implicit_externs, options.allow_implicit_semicolons, options.enforce_mutable_bindings, + options.import_scan_mode, dialect, ) } @@ -86,6 +138,7 @@ fn parse_with_parser( allow_implicit_externs: bool, allow_implicit_semicolons: bool, enforce_mutable_bindings: bool, + import_scan_mode: bool, dialect: &'static dyn ParserDialect, ) -> Result { let mut parser = Parser::new( @@ -94,6 +147,7 @@ fn parse_with_parser( allow_implicit_externs, allow_implicit_semicolons, enforce_mutable_bindings, + import_scan_mode, dialect, )?; let stmts = parser.parse_program()?; @@ -107,6 +161,8 @@ fn parse_with_parser( function_impls: parser.function_impls(), stmt_sources: Vec::new(), function_sources: HashMap::new(), + use_declarations: parser.use_declarations(), + implicit_extern_names: parser.implicit_extern_names(), }) } @@ -142,6 +198,8 @@ fn parse_repl_with_parser( function_impls: parser.function_impls(), stmt_sources: Vec::new(), function_sources: HashMap::new(), + use_declarations: parser.use_declarations(), + implicit_extern_names: parser.implicit_extern_names(), }, bindings, }) @@ -153,9 +211,10 @@ fn parse_lowered_with_mapping( allow_implicit_externs: bool, allow_implicit_semicolons: bool, enforce_mutable_bindings: bool, + original_source_id: u32, ) -> Result { let mut source_map = SourceMap::new(); - let original_source_id = source_map.add_source("", original_source.to_string()); + source_map.add_source_at(original_source_id, "", original_source.to_string()); let lowered_source_id = source_map.add_source("", lowered.text.clone()); match parse_with_parser( @@ -164,6 +223,7 @@ fn parse_lowered_with_mapping( allow_implicit_externs, allow_implicit_semicolons, enforce_mutable_bindings, + false, rustscript::parser_dialect(), ) { Ok(mut ir) => { diff --git a/src/compiler/ir.rs b/src/compiler/ir.rs index e17494a5..a0f8388b 100644 --- a/src/compiler/ir.rs +++ b/src/compiler/ir.rs @@ -4,6 +4,7 @@ use crate::ValueType; use crate::builtins::default_host_callable; use super::ParseError; +use super::modules::SymbolId; pub type LocalSlot = u16; @@ -186,6 +187,25 @@ pub enum Expr { String(String), Bytes(Vec), FunctionRef(u16, Vec), + /// A function value whose target was resolved to a compiler-owned module + /// symbol before unit merge (milestone 4). + /// + /// Produced by the source loader's resolution pass for imported function + /// values and lowered by `linker::merge_units` into a plain + /// [`Expr::FunctionRef`] against the merged flat function table. + ModuleFunctionRef(SymbolId, Vec), + /// A function value reference whose target is not yet resolved (module + /// mode only). + /// + /// Produced by the parser in module mode when a function value refers to + /// a name the parser cannot resolve locally (an imported function binding + /// whose export table only the source loader knows). The loader's + /// resolution pass maps it to [`Expr::ModuleFunctionRef`] before unit + /// merge, so downstream passes never observe it. + UnresolvedFunctionRef { + name: String, + type_args: Vec, + }, OptionalGet { container: Box, key: Box, @@ -198,6 +218,17 @@ pub enum Expr { fallback: Box, }, Call(u16, Vec, Vec), + /// A call whose target was resolved to a compiler-owned module symbol + /// before unit merge (milestone 4). + /// + /// The source loader's resolution pass rewrites calls to imported + /// functions into this form, carrying the [`SymbolId`] of the source + /// module's declaration; `linker::merge_units` lowers it back into a + /// plain [`Expr::Call`] against the merged flat function table. Unlike + /// [`Expr::Call`]'s flat index, the symbol identity never depends on + /// unit-local index assignment or on the source name, so same-named + /// declarations in independent modules resolve to distinct targets. + ModuleCall(SymbolId, Vec, Vec), LocalCall(LocalSlot, Vec, Vec), Closure(ClosureExpr), ClosureCall(ClosureExpr, Vec), @@ -342,6 +373,10 @@ pub struct FunctionDecl { pub type_params: Vec, pub exported: bool, pub return_type: ValueType, + /// Semantic symbol owned by the declaring module, assigned by the source + /// loader after parse (milestone 3). `None` for IR that has not been + /// attached to a module yet (parser output, REPL snippets). + pub symbol: Option, } #[derive(Clone, Debug)] @@ -364,6 +399,18 @@ pub struct FrontendIr { pub function_impls: HashMap, pub stmt_sources: Vec>, pub function_sources: HashMap, + /// Structured `use` directives parsed from RustScript source, with spans + /// and clauses. Consumed by the source loader for import discovery. + pub use_declarations: Vec, + /// Names created by the parser's implicit-extern fallback (module mode). + /// + /// Module-mode parses tolerate calls whose target only the source loader + /// can resolve (imported module functions, module namespace members). + /// These synthetic declarations must never receive a module symbol or a + /// flat entry; the loader resolves their call sites or rejects them. + /// Plain (non-module) parses leave this empty because implicit externs are + /// disabled there. + pub implicit_extern_names: Vec, } pub struct LocalIrBuilder { @@ -475,6 +522,7 @@ impl LocalIrBuilder { type_params: Vec::new(), exported: false, return_type: ValueType::Unknown, + symbol: None, }); self.function_meta.insert(name.to_string(), (index, arity)); Ok(()) @@ -521,6 +569,8 @@ impl LocalIrBuilder { function_impls: HashMap::new(), stmt_sources: Vec::new(), function_sources: HashMap::new(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), } } diff --git a/src/compiler/lifetime/availability.rs b/src/compiler/lifetime/availability.rs index e9f977bf..10c5562c 100644 --- a/src/compiler/lifetime/availability.rs +++ b/src/compiler/lifetime/availability.rs @@ -665,7 +665,9 @@ impl AvailabilityAnalyzer { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => Ok(state.clone()), + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => Ok(state.clone()), Expr::Var(index) => { self.require_available(*index, state, line)?; self.require_local_not_moved(*index, state, line)?; @@ -720,6 +722,9 @@ impl AvailabilityAnalyzer { let then_state = self.analyze_expr(fallback, &value_state, line)?; Ok(self.merge_states(then_state, value_state)) } + // Resolved module calls (pre-merge only) analyze their arguments; + // interprocedural effects apply to the post-merge flat call. + Expr::ModuleCall(_, _, args) => self.analyze_args(args, state, line), Expr::Call(index, _, args) => { if !self.enable_local_move_semantics { if let Some(root_slot) = self.extract_collection_mutation_root(*index, args) { diff --git a/src/compiler/lifetime/availability/captures.rs b/src/compiler/lifetime/availability/captures.rs index f234fc17..82cb1774 100644 --- a/src/compiler/lifetime/availability/captures.rs +++ b/src/compiler/lifetime/availability/captures.rs @@ -329,7 +329,9 @@ impl AvailabilityAnalyzer { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => {} + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) => { if *index == captured_slot { *seen = true; @@ -358,7 +360,7 @@ impl AvailabilityAnalyzer { self.capture_mode_for_expr(value, captured_slot, context, mode, seen); self.capture_mode_for_expr(fallback, captured_slot, context, mode, seen); } - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => { for arg in args { self.capture_mode_for_expr(arg, captured_slot, context, mode, seen); } diff --git a/src/compiler/lifetime/availability/consumption.rs b/src/compiler/lifetime/availability/consumption.rs index f410291b..c37e1226 100644 --- a/src/compiler/lifetime/availability/consumption.rs +++ b/src/compiler/lifetime/availability/consumption.rs @@ -171,7 +171,9 @@ pub(super) fn expr_uses_slot(expr: &Expr, slot: LocalSlot) -> bool { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => false, + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => false, Expr::Var(index) | Expr::MoveVar(index) => *index == slot, Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => *root == slot, Expr::OptionalGet { @@ -190,7 +192,7 @@ pub(super) fn expr_uses_slot(expr: &Expr, slot: LocalSlot) -> bool { value_slot, fallback, } => *value_slot == slot || expr_uses_slot(value, slot) || expr_uses_slot(fallback, slot), - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => { args.iter().any(|arg| expr_uses_slot(arg, slot)) } Expr::Closure(closure) => { @@ -376,6 +378,8 @@ pub(super) fn collect_consumed_positions_from_expr( | Expr::Bytes(_) | Expr::String(_) | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } | Expr::Var(_) => {} Expr::MoveVar(slot) => { if let Some(position) = function_impl @@ -459,6 +463,18 @@ pub(super) fn collect_consumed_positions_from_expr( } } } + // Resolved module calls (pre-merge only) have no per-unit consumed + // position table; their arguments are still scanned. + Expr::ModuleCall(_, _, args) => { + for arg in args { + collect_consumed_positions_from_expr( + arg, + function_impl, + known_consumed_positions, + out, + ); + } + } Expr::LocalCall(_, _, args) => { for arg in args { collect_consumed_positions_from_expr( diff --git a/src/compiler/lifetime/liveness.rs b/src/compiler/lifetime/liveness.rs index 175437bc..7f873388 100644 --- a/src/compiler/lifetime/liveness.rs +++ b/src/compiler/lifetime/liveness.rs @@ -1,4 +1,3 @@ -use std::cell::RefCell; use std::cmp::Reverse; use std::collections::{BTreeSet, HashMap, HashSet}; @@ -16,10 +15,7 @@ struct DefInfo { pub(super) struct LivenessRewriter { local_count: usize, clearable_slots: Vec, - conservative_call_indices: HashSet, function_impls: HashMap, - function_footprint_cache: RefCell>, - full_footprint: LiveSet, } impl LivenessRewriter { @@ -32,19 +28,10 @@ impl LivenessRewriter { // inline-call parameters, and parser-generated temporaries, so excluding // them leaves stale values past their last use. let clearable_slots = vec![true; local_count]; - let conservative_call_indices = function_impls - .iter() - .filter_map(|(index, function_impl)| { - function_impl_uses_local_call(function_impl).then_some(*index) - }) - .collect::>(); Self { local_count, clearable_slots, - conservative_call_indices, function_impls: function_impls.clone(), - function_footprint_cache: RefCell::new(HashMap::new()), - full_footprint: vec![true; local_count], } } @@ -437,7 +424,9 @@ impl LivenessRewriter { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => {} + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) | Expr::MoveVar(index) => self.mark_live(live, *index), Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => { self.mark_live(live, *root) @@ -462,14 +451,22 @@ impl LivenessRewriter { self.add_expr_uses(value, live); self.add_expr_uses(fallback, live); } - Expr::Call(index, _, args) => { + Expr::Call(_, _, args) => { + // Known named script calls execute in a separate runtime frame + // with its own local_base: the callee body footprint is + // analyzed inside the callee frame and must not be unioned + // into the caller live set. Arguments and caller-after-call + // uses stay live in the caller. for arg in args { self.add_expr_uses(arg, live); } - if self.function_impls.contains_key(index) { - let mut stack = Vec::new(); - let footprint = self.function_footprint(*index, &mut stack); - self.union_inplace(live, &footprint); + } + // Resolved module calls (pre-merge only) contribute their + // arguments' uses; the callee lives in another unit and its + // footprint is folded in by the post-merge call lowering. + Expr::ModuleCall(_, _, args) => { + for arg in args { + self.add_expr_uses(arg, live); } } Expr::LocalCall(index, _, args) => { @@ -615,346 +612,7 @@ impl LivenessRewriter { } live_out } - - fn function_footprint(&self, index: u16, stack: &mut Vec) -> LiveSet { - if let Some(cached) = self.function_footprint_cache.borrow().get(&index).cloned() { - return cached; - } - if stack.contains(&index) || self.conservative_call_indices.contains(&index) { - return self.full_footprint.clone(); - } - let Some(function_impl) = self.function_impls.get(&index) else { - return self.empty_set(); - }; - - stack.push(index); - let mut footprint = self.empty_set(); - for slot in &function_impl.param_slots { - self.mark_live(&mut footprint, *slot); - } - for (_, captured_slot) in &function_impl.capture_copies { - self.mark_live(&mut footprint, *captured_slot); - } - for stmt in &function_impl.body_stmts { - self.collect_stmt_footprint(stmt, &mut footprint, stack); - } - self.collect_expr_footprint(&function_impl.body_expr, &mut footprint, stack); - stack.pop(); - - self.function_footprint_cache - .borrow_mut() - .insert(index, footprint.clone()); - footprint - } - - fn closure_footprint(&self, closure: &ClosureExpr, stack: &mut Vec) -> LiveSet { - if expr_contains_local_call(&closure.body) { - return self.full_footprint.clone(); - } - - let mut footprint = self.empty_set(); - for slot in &closure.param_slots { - self.mark_live(&mut footprint, *slot); - } - for (source_slot, captured_slot) in &closure.capture_copies { - self.mark_live(&mut footprint, *source_slot); - self.mark_live(&mut footprint, *captured_slot); - } - self.collect_expr_footprint(&closure.body, &mut footprint, stack); - footprint - } - - fn collect_stmt_footprint(&self, stmt: &Stmt, footprint: &mut LiveSet, stack: &mut Vec) { - match stmt { - Stmt::Noop { .. } | Stmt::Break { .. } | Stmt::Continue { .. } => {} - Stmt::FuncDecl { - index, has_impl, .. - } => { - if *has_impl && let Some(function_impl) = self.function_impls.get(index) { - for (source_slot, captured_slot) in &function_impl.capture_copies { - self.mark_live(footprint, *source_slot); - self.mark_live(footprint, *captured_slot); - } - } - } - Stmt::Drop { index, .. } => self.mark_live(footprint, *index), - Stmt::Let { index, expr, .. } | Stmt::Assign { index, expr, .. } => { - self.mark_live(footprint, *index); - self.collect_expr_footprint(expr, footprint, stack); - } - Stmt::ClosureLet { closure, .. } => { - for (source_slot, captured_slot) in &closure.capture_copies { - self.mark_live(footprint, *source_slot); - self.mark_live(footprint, *captured_slot); - } - } - Stmt::Expr { expr, .. } => self.collect_expr_footprint(expr, footprint, stack), - Stmt::IfElse { - condition, - then_branch, - else_branch, - .. - } => { - self.collect_expr_footprint(condition, footprint, stack); - for nested in then_branch { - self.collect_stmt_footprint(nested, footprint, stack); - } - for nested in else_branch { - self.collect_stmt_footprint(nested, footprint, stack); - } - } - Stmt::For { - init, - condition, - post, - body, - .. - } => { - self.collect_stmt_footprint(init, footprint, stack); - self.collect_expr_footprint(condition, footprint, stack); - self.collect_stmt_footprint(post, footprint, stack); - for nested in body { - self.collect_stmt_footprint(nested, footprint, stack); - } - } - Stmt::While { - condition, body, .. - } => { - self.collect_expr_footprint(condition, footprint, stack); - for nested in body { - self.collect_stmt_footprint(nested, footprint, stack); - } - } - } - } - - fn collect_expr_footprint(&self, expr: &Expr, footprint: &mut LiveSet, stack: &mut Vec) { - match expr { - Expr::Null - | Expr::Int(_) - | Expr::Float(_) - | Expr::Bool(_) - | Expr::Bytes(_) - | Expr::String(_) - | Expr::FunctionRef(..) => {} - Expr::Var(index) | Expr::MoveVar(index) | Expr::LocalCall(index, _, _) => { - self.mark_live(footprint, *index); - } - Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => { - self.mark_live(footprint, *root); - } - Expr::OptionalGet { - container, - key, - container_slot, - key_slot, - } => { - self.mark_live(footprint, *container_slot); - self.mark_live(footprint, *key_slot); - self.collect_expr_footprint(container, footprint, stack); - self.collect_expr_footprint(key, footprint, stack); - } - Expr::OptionUnwrapOr { - value, - value_slot, - fallback, - } => { - self.mark_live(footprint, *value_slot); - self.collect_expr_footprint(value, footprint, stack); - self.collect_expr_footprint(fallback, footprint, stack); - } - Expr::Call(index, _, args) => { - let called = self.function_footprint(*index, stack); - self.union_inplace(footprint, &called); - for arg in args { - self.collect_expr_footprint(arg, footprint, stack); - } - } - Expr::Closure(closure) => { - for slot in &closure.param_slots { - self.mark_live(footprint, *slot); - } - for (source_slot, captured_slot) in &closure.capture_copies { - self.mark_live(footprint, *source_slot); - self.mark_live(footprint, *captured_slot); - } - } - Expr::ClosureCall(closure, args) => { - let called = self.closure_footprint(closure, stack); - self.union_inplace(footprint, &called); - for arg in args { - self.collect_expr_footprint(arg, footprint, stack); - } - } - Expr::Add(lhs, rhs) - | Expr::Sub(lhs, rhs) - | Expr::Mul(lhs, rhs) - | Expr::Div(lhs, rhs) - | Expr::Mod(lhs, rhs) - | Expr::And(lhs, rhs) - | Expr::Or(lhs, rhs) - | Expr::Eq(lhs, rhs) - | Expr::Lt(lhs, rhs) - | Expr::Gt(lhs, rhs) => { - self.collect_expr_footprint(lhs, footprint, stack); - self.collect_expr_footprint(rhs, footprint, stack); - } - Expr::Neg(inner) - | Expr::Not(inner) - | Expr::ToOwned(inner) - | Expr::Borrow(inner) - | Expr::BorrowMut(inner) => self.collect_expr_footprint(inner, footprint, stack), - Expr::IfElse { - condition, - then_expr, - else_expr, - } => { - self.collect_expr_footprint(condition, footprint, stack); - self.collect_expr_footprint(then_expr, footprint, stack); - self.collect_expr_footprint(else_expr, footprint, stack); - } - Expr::Match { - value_slot, - result_slot, - value, - arms, - default, - } => { - self.mark_live(footprint, *value_slot); - self.mark_live(footprint, *result_slot); - self.collect_expr_footprint(value, footprint, stack); - for (pattern, arm_expr) in arms { - if let Some(binding_slot) = pattern.binding_slot() { - self.mark_live(footprint, binding_slot); - } - self.collect_expr_footprint(arm_expr, footprint, stack); - } - self.collect_expr_footprint(default, footprint, stack); - } - Expr::Block { stmts, expr } => { - for stmt in stmts { - self.collect_stmt_footprint(stmt, footprint, stack); - } - self.collect_expr_footprint(expr, footprint, stack); - } - } - } } - -fn function_impl_uses_local_call(function_impl: &FunctionImpl) -> bool { - function_impl - .body_stmts - .iter() - .any(stmt_contains_local_call) - || expr_contains_local_call(&function_impl.body_expr) -} - -fn stmt_contains_local_call(stmt: &Stmt) -> bool { - match stmt { - Stmt::Noop { .. } - | Stmt::FuncDecl { .. } - | Stmt::Break { .. } - | Stmt::Continue { .. } - | Stmt::Drop { .. } => false, - Stmt::Let { expr, .. } | Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { - expr_contains_local_call(expr) - } - Stmt::ClosureLet { closure, .. } => expr_contains_local_call(&closure.body), - Stmt::IfElse { - condition, - then_branch, - else_branch, - .. - } => { - expr_contains_local_call(condition) - || then_branch.iter().any(stmt_contains_local_call) - || else_branch.iter().any(stmt_contains_local_call) - } - Stmt::For { - init, - condition, - post, - body, - .. - } => { - stmt_contains_local_call(init) - || expr_contains_local_call(condition) - || stmt_contains_local_call(post) - || body.iter().any(stmt_contains_local_call) - } - Stmt::While { - condition, body, .. - } => expr_contains_local_call(condition) || body.iter().any(stmt_contains_local_call), - } -} - -fn expr_contains_local_call(expr: &Expr) -> bool { - match expr { - Expr::LocalCall(..) => true, - Expr::Null - | Expr::Int(_) - | Expr::Float(_) - | Expr::Bool(_) - | Expr::Bytes(_) - | Expr::String(_) - | Expr::FunctionRef(..) - | Expr::Var(_) - | Expr::MoveVar(_) - | Expr::MoveField { .. } - | Expr::MoveIndex { .. } => false, - Expr::OptionalGet { container, key, .. } => { - expr_contains_local_call(container) || expr_contains_local_call(key) - } - Expr::OptionUnwrapOr { - value, fallback, .. - } => expr_contains_local_call(value) || expr_contains_local_call(fallback), - Expr::Call(_, _, args) => args.iter().any(expr_contains_local_call), - Expr::Closure(closure) => expr_contains_local_call(&closure.body), - Expr::ClosureCall(closure, args) => { - args.iter().any(expr_contains_local_call) || expr_contains_local_call(&closure.body) - } - Expr::Add(lhs, rhs) - | Expr::Sub(lhs, rhs) - | Expr::Mul(lhs, rhs) - | Expr::Div(lhs, rhs) - | Expr::Mod(lhs, rhs) - | Expr::And(lhs, rhs) - | Expr::Or(lhs, rhs) - | Expr::Eq(lhs, rhs) - | Expr::Lt(lhs, rhs) - | Expr::Gt(lhs, rhs) => expr_contains_local_call(lhs) || expr_contains_local_call(rhs), - Expr::Neg(inner) - | Expr::Not(inner) - | Expr::ToOwned(inner) - | Expr::Borrow(inner) - | Expr::BorrowMut(inner) => expr_contains_local_call(inner), - Expr::IfElse { - condition, - then_expr, - else_expr, - } => { - expr_contains_local_call(condition) - || expr_contains_local_call(then_expr) - || expr_contains_local_call(else_expr) - } - Expr::Match { - value, - arms, - default, - .. - } => { - expr_contains_local_call(value) - || arms - .iter() - .any(|(_, arm_expr)| expr_contains_local_call(arm_expr)) - || expr_contains_local_call(default) - } - Expr::Block { stmts, expr } => { - stmts.iter().any(stmt_contains_local_call) || expr_contains_local_call(expr) - } - } -} - fn stmt_line(stmt: &Stmt) -> u32 { match stmt { Stmt::Noop { line } @@ -977,7 +635,6 @@ pub(super) struct LocalSlotAllocator { liveness: LivenessRewriter, function_impls: HashMap, adjacency: Vec>, - function_footprint_cache: HashMap, full_footprint: LiveSet, } @@ -993,7 +650,6 @@ impl LocalSlotAllocator { liveness, function_impls: function_impls.clone(), adjacency: (0..local_count).map(|_| HashSet::new()).collect(), - function_footprint_cache: HashMap::new(), full_footprint: vec![true; local_count], } } @@ -1133,7 +789,9 @@ impl LocalSlotAllocator { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => {} + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) | Expr::MoveVar(index) => { self.add_slot_live_edges(*index, &live_during); } @@ -1160,14 +818,21 @@ impl LocalSlotAllocator { self.collect_expr_constraints(value, &live_during)?; self.collect_expr_constraints(fallback, &live_during)?; } - Expr::Call(index, _, args) => { + Expr::Call(_, _, args) => { + // Arguments are evaluated in the caller frame, so their + // constraints belong here. The callee body runs in a separate + // runtime frame with its own local_base, so caller/callee + // cross-live edges would only needlessly separate slots that + // frame bases already isolate. for arg in args { self.collect_expr_constraints(arg, &live_during)?; } - if self.function_impls.contains_key(index) { - let mut stack = Vec::new(); - let footprint = self.function_footprint(*index, &mut stack); - self.add_cross_live_with_set(&live_during, &footprint); + } + // Resolved module calls (pre-merge only) constrain their + // arguments; the callee's footprint is folded in post-merge. + Expr::ModuleCall(_, _, args) => { + for arg in args { + self.collect_expr_constraints(arg, &live_during)?; } } Expr::LocalCall(index, _, args) => { @@ -1245,31 +910,6 @@ impl LocalSlotAllocator { Ok(()) } - fn function_footprint(&mut self, index: u16, stack: &mut Vec) -> LiveSet { - if let Some(cached) = self.function_footprint_cache.get(&index) { - return cached.clone(); - } - if stack.contains(&index) { - return self.full_footprint.clone(); - } - let Some(function_impl) = self.function_impls.get(&index).cloned() else { - return self.liveness.empty_set(); - }; - stack.push(index); - let mut footprint = self.liveness.empty_set(); - for slot in &function_impl.param_slots { - self.mark_set_slot(&mut footprint, *slot); - } - for stmt in &function_impl.body_stmts { - self.collect_stmt_footprint(stmt, &mut footprint, stack); - } - self.collect_expr_footprint(&function_impl.body_expr, &mut footprint, stack); - stack.pop(); - self.function_footprint_cache - .insert(index, footprint.clone()); - footprint - } - fn closure_footprint(&mut self, closure: &ClosureExpr, stack: &mut Vec) -> LiveSet { let mut footprint = self.liveness.empty_set(); for slot in &closure.param_slots { @@ -1357,7 +997,9 @@ impl LocalSlotAllocator { | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => {} + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) | Expr::MoveVar(index) | Expr::LocalCall(index, _, _) => { self.mark_set_slot(set, *index) } @@ -1384,15 +1026,15 @@ impl LocalSlotAllocator { self.collect_expr_footprint(value, set, stack); self.collect_expr_footprint(fallback, set, stack); } - Expr::Call(index, _, args) => { - if self.function_impls.contains_key(index) { - let footprint = self.function_footprint(*index, stack); - for (slot, used) in footprint.iter().enumerate() { - if *used { - set[slot] = true; - } - } + Expr::Call(_, _, args) => { + // The callee runs in its own frame even when called from a + // closure body, so only argument slots join the caller-side + // footprint. + for arg in args { + self.collect_expr_footprint(arg, set, stack); } + } + Expr::ModuleCall(_, _, args) => { for arg in args { self.collect_expr_footprint(arg, set, stack); } @@ -1745,6 +1387,8 @@ fn collect_persistent_closure_sources_from_expr(expr: &Expr, slots: &mut BTreeSe | Expr::String(_) | Expr::Bytes(_) | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } | Expr::Var(_) | Expr::MoveVar(_) | Expr::MoveField { .. } @@ -1759,7 +1403,7 @@ fn collect_persistent_closure_sources_from_expr(expr: &Expr, slots: &mut BTreeSe collect_persistent_closure_sources_from_expr(value, slots); collect_persistent_closure_sources_from_expr(fallback, slots); } - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => { for arg in args { collect_persistent_closure_sources_from_expr(arg, slots); } @@ -1897,8 +1541,10 @@ fn remap_expr_slots(expr: &mut Expr, mapping: &[LocalSlot]) -> Result<(), ParseE | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) => {} - Expr::FunctionRef(..) => {} - Expr::Call(_, _, args) => { + Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} + Expr::Call(_, _, args) | Expr::ModuleCall(_, _, args) => { for arg in args { remap_expr_slots(arg, mapping)?; } diff --git a/src/compiler/lifetime/mod.rs b/src/compiler/lifetime/mod.rs index b4e57dd1..bdb0b962 100644 --- a/src/compiler/lifetime/mod.rs +++ b/src/compiler/lifetime/mod.rs @@ -1,3 +1,32 @@ +//! Frame-local lifetime analysis. +//! +//! # Same-frame interference +//! +//! Locals that are simultaneously live inside one execution frame share a +//! single interference domain: the coloring pass must give them distinct +//! relative slot numbers. This applies to the root body and to each named +//! function body independently — argument evaluation and values used after +//! a call keep the caller's slots live across the call. +//! +//! # Cross-frame reuse +//! +//! Every script invocation allocates its own runtime frame with a fresh +//! `local_base` (see `docs/callable-runtime.md`). A statically resolved +//! named call (`Expr::Call`) therefore contributes only caller-side +//! argument uses to the caller live set; the callee body's locals are +//! analyzed inside the callee frame and never union into the caller. +//! Locals from different frames may reuse the same relative slot numbers — +//! the runtime frame bases already separate them, so cross-frame live +//! ranges need no interference edges. +//! +//! # Conservative dynamic paths +//! +//! Dynamic targets keep their pre-frame conservatism on purpose: +//! `Expr::LocalCall` marks the whole live set because the invoked slot can +//! hold an inline closure whose captures are not visible from the call +//! expression, and closure bodies contribute their transitive footprint so +//! captured slots stay live for the duration of the call. + mod availability; mod liveness; diff --git a/src/compiler/linker.rs b/src/compiler/linker.rs index 677840b6..cdef2c29 100644 --- a/src/compiler/linker.rs +++ b/src/compiler/linker.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use crate::builtins::BuiltinFunction; @@ -6,18 +6,42 @@ use crate::builtins::BuiltinFunction; use super::{ ParseError, SourceError, SourcePathError, ir::{Expr, FrontendIr, FunctionDecl, FunctionImpl, LocalSlot, Stmt, StructDecl}, + modules::{ModuleId, SymbolId}, }; pub(super) struct ParsedUnit { pub(super) parsed: FrontendIr, - pub(super) scope_prefix: Option, + /// Deterministic scope identity for the unit's local bindings at the flat + /// bytecode boundary. `None` for the root unit (which keeps bare names); + /// otherwise a mangled full-module identity computed by the loader, never + /// a bare file stem (milestone 4). + pub(super) scope_identity: Option, pub(super) source_name: String, + /// Semantic module identity assigned by the module graph during discovery. + /// Consumed by milestone 4+ symbol resolution; carried on the unit so the + /// link between parsed IR and graph node survives the merge pipeline. + /// (The loader resolves call sites with it before merge; the flat merge + /// itself keys on [`SymbolId`].) + #[allow(dead_code)] + pub(super) module: ModuleId, + /// Graph `SourceId` of the unit's module (milestone 5). Every span the + /// unit's IR carries references this id in the compilation-wide + /// [`SourceMap`](crate::compiler::source_map::SourceMap), so merged + /// diagnostics always render from the owning source. + #[allow(dead_code)] + pub(super) source_id: u32, } -pub(super) fn sanitize_scope_prefix(path: &Path) -> String { - path.file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("module") +/// Deterministic flat-boundary scope identity for a non-root module's local +/// bindings. +/// +/// Encodes the full canonical module identity (never a bare file stem) plus +/// the compiler-owned [`ModuleId`], so same-stem modules in different +/// directories and same-named locals across independent modules never collide +/// at the flat boundary. +pub(super) fn module_scope_prefix(identity: &Path, module: ModuleId) -> String { + let sanitized: String = identity + .to_string_lossy() .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || ch == '_' { @@ -26,7 +50,16 @@ pub(super) fn sanitize_scope_prefix(path: &Path) -> String { '_' } }) - .collect() + .collect(); + format!("{sanitized}__m{}", module.0) +} + +/// Deterministic flat name for a module function whose source name is already +/// claimed by another flat entry. The mangling encodes the compiler-owned +/// module identity, so it is stable across compilations and never depends on +/// discovery-order-dependent string synthesis. +fn deterministic_flat_name(name: &str, symbol: SymbolId) -> String { + format!("{}__m{}", name, symbol.module.0) } pub(super) fn merge_units(units: Vec) -> Result { @@ -38,22 +71,39 @@ pub(super) fn merge_units(units: Vec) -> Result::new(); let mut merged_function_sources = HashMap::::new(); - let mut function_index_by_name = HashMap::::new(); + + // Milestone 4 flat identity maps. + // + // Module functions (declarations with implementations) are merged by + // compiler-owned `SymbolId`, so same-named declarations in independent + // modules each get their own flat entry. Host imports (declarations + // without implementations) keep name-keyed deduplication: their names are + // the runtime binding surface (`program.imports`, `Vm::bind_function`), + // so the legacy merge semantics apply verbatim. + let mut flat_index_by_symbol = HashMap::::new(); + let mut host_index_by_name = HashMap::::new(); + // Every flat name claimed so far. Module functions that collide are + // deterministically mangled with their module identity; host imports are + // deduplicated by name before ever reaching this set. + let mut claimed_flat_names = HashSet::::new(); + let mut local_base = 0usize; for unit in units { let source_name = unit.source_name.clone(); - let function_map = remap_functions( - &unit.parsed.functions, + let function_map = register_unit_functions( + &unit, &mut merged_functions, - &mut function_index_by_name, + &mut flat_index_by_symbol, + &mut host_index_by_name, + &mut claimed_flat_names, )?; let unit_local_base = local_base; let unit_local_count = unit.parsed.locals; let mut remapped_stmts = unit.parsed.stmts; for stmt in &mut remapped_stmts { - remap_stmt_indices(stmt, unit_local_base, &function_map)?; + remap_stmt_indices(stmt, unit_local_base, &function_map, &flat_index_by_symbol)?; } merged_stmt_sources.extend(std::iter::repeat_n( Some(source_name.clone()), @@ -63,8 +113,8 @@ pub(super) fn merge_units(units: Vec) -> Result) -> Result) -> Result Result { + u16::try_from(merged_functions.len()).map_err(|_| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: "too many functions across imported modules".to_string(), + })) + }) +} + +/// Register one unit's declarations in the flat function table and return the +/// unit-index → flat-index map. +/// +/// Synthetic prelude declarations (symbol-less, mirroring imported bindings) +/// never become flat entries: the loader already resolved their call sites to +/// [`Expr::ModuleCall`] with the target's [`SymbolId`]. +fn register_unit_functions( + unit: &ParsedUnit, merged_functions: &mut Vec, - function_index_by_name: &mut HashMap, + flat_index_by_symbol: &mut HashMap, + host_index_by_name: &mut HashMap, + claimed_flat_names: &mut HashSet, ) -> Result, SourcePathError> { let mut map = HashMap::new(); - for func in unit_functions { - let merged_index = if let Some(existing_index) = function_index_by_name.get(&func.name) { - let existing = &mut merged_functions[*existing_index as usize]; - if existing.arity != func.arity { - return Err(SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: format!( - "function '{}' declared with conflicting arity {} vs {}", - func.name, existing.arity, func.arity - ), - }))); - } - if existing.return_type != func.return_type { - match (existing.return_type, func.return_type) { - (crate::ValueType::Unknown, known) => existing.return_type = known, - (known, crate::ValueType::Unknown) => existing.return_type = known, - (lhs, rhs) => { - return Err(SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: format!( - "function '{}' declared with conflicting return type {} vs {}", - func.name, - value_type_name(lhs), - value_type_name(rhs) - ), - }))); - } - } - } - if existing.return_schema != func.return_schema { - match (&existing.return_schema, &func.return_schema) { - (None, Some(schema)) => existing.return_schema = Some(schema.clone()), - (Some(_), None) => {} - (Some(lhs), Some(rhs)) if lhs == rhs => {} - _ => { - return Err(SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: format!( - "function '{}' declared with conflicting return schemas across imported modules", - func.name - ), - }))); - } - } - } - if existing.type_params != func.type_params { - if existing.type_params.is_empty() { - existing.type_params = func.type_params.clone(); - } else if !func.type_params.is_empty() { - return Err(SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: format!( - "function '{}' declared with conflicting type parameters across imported modules", - func.name - ), - }))); - } - } - if existing.arg_schemas != func.arg_schemas { - if existing.arg_schemas.iter().all(Option::is_none) { - existing.arg_schemas = func.arg_schemas.clone(); - } else if !func.arg_schemas.iter().all(Option::is_none) { - return Err(SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: format!( - "function '{}' declared with conflicting parameter schemas across imported modules", - func.name - ), - }))); - } - } - if function_args_are_placeholders(&existing.args) - && !function_args_are_placeholders(&func.args) - { - existing.args = func.args.clone(); + for func in &unit.parsed.functions { + let Some(symbol) = func.symbol else { + // Synthetic prelude/stub declaration; resolved by the loader. + continue; + }; + if let Some(&existing) = flat_index_by_symbol.get(&symbol) { + map.insert(func.index, existing); + continue; + } + let has_impl = unit.parsed.function_impls.contains_key(&func.index); + let flat = if !has_impl { + // Host import: name-keyed deduplication preserves the legacy + // merge semantics and the runtime name-binding surface. + if let Some(&existing) = host_index_by_name.get(&func.name) { + merge_host_import_metadata(&mut merged_functions[existing as usize], func)?; + flat_index_by_symbol.insert(symbol, existing); + map.insert(func.index, existing); + continue; } - existing.exported = existing.exported || func.exported; - *existing_index - } else { - let next_index = u16::try_from(merged_functions.len()).map_err(|_| { - SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: "too many functions across imported modules".to_string(), - })) - })?; + let flat = next_flat_index(merged_functions)?; merged_functions.push(FunctionDecl { name: func.name.clone(), arity: func.arity, - index: next_index, + index: flat, + args: func.args.clone(), + arg_schemas: func.arg_schemas.clone(), + return_schema: func.return_schema.clone(), + type_params: func.type_params.clone(), + exported: func.exported, + return_type: func.return_type, + symbol: Some(symbol), + }); + host_index_by_name.insert(func.name.clone(), flat); + claimed_flat_names.insert(func.name.clone()); + flat + } else { + // Module function: one flat entry per symbol; the source name is + // kept unless another flat entry already claimed it, in which + // case it is deterministically mangled with the module identity. + let flat = next_flat_index(merged_functions)?; + let flat_name = if claimed_flat_names.insert(func.name.clone()) { + func.name.clone() + } else { + deterministic_flat_name(&func.name, symbol) + }; + merged_functions.push(FunctionDecl { + name: flat_name, + arity: func.arity, + index: flat, args: func.args.clone(), arg_schemas: func.arg_schemas.clone(), return_schema: func.return_schema.clone(), type_params: func.type_params.clone(), exported: func.exported, return_type: func.return_type, + symbol: Some(symbol), }); - function_index_by_name.insert(func.name.clone(), next_index); - next_index + flat }; - map.insert(func.index, merged_index); + flat_index_by_symbol.insert(symbol, flat); + map.insert(func.index, flat); } Ok(map) } +/// Replicate the legacy name-merge metadata rules for host imports that are +/// declared by more than one unit: arity conflicts are errors, `Unknown` +/// return types are refined, and schemas/type parameters merge. +fn merge_host_import_metadata( + existing: &mut FunctionDecl, + func: &FunctionDecl, +) -> Result<(), SourcePathError> { + if existing.arity != func.arity { + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: format!( + "function '{}' declared with conflicting arity {} vs {}", + func.name, existing.arity, func.arity + ), + }))); + } + if existing.return_type != func.return_type { + match (existing.return_type, func.return_type) { + (crate::ValueType::Unknown, known) => existing.return_type = known, + (known, crate::ValueType::Unknown) => existing.return_type = known, + (lhs, rhs) => { + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: format!( + "function '{}' declared with conflicting return type {} vs {}", + func.name, + value_type_name(lhs), + value_type_name(rhs) + ), + }))); + } + } + } + if existing.return_schema != func.return_schema { + match (&existing.return_schema, &func.return_schema) { + (None, Some(schema)) => existing.return_schema = Some(schema.clone()), + (Some(_), None) => {} + (Some(lhs), Some(rhs)) if lhs == rhs => {} + _ => { + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: format!( + "function '{}' declared with conflicting return schemas across imported modules", + func.name + ), + }))); + } + } + } + if existing.type_params != func.type_params { + if existing.type_params.is_empty() { + existing.type_params = func.type_params.clone(); + } else if !func.type_params.is_empty() { + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: format!( + "function '{}' declared with conflicting type parameters across imported modules", + func.name + ), + }))); + } + } + if existing.arg_schemas != func.arg_schemas { + if existing.arg_schemas.iter().all(Option::is_none) { + existing.arg_schemas = func.arg_schemas.clone(); + } else if !func.arg_schemas.iter().all(Option::is_none) { + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: format!( + "function '{}' declared with conflicting parameter schemas across imported modules", + func.name + ), + }))); + } + } + if function_args_are_placeholders(&existing.args) && !function_args_are_placeholders(&func.args) + { + existing.args = func.args.clone(); + } + existing.exported = existing.exported || func.exported; + Ok(()) +} + fn function_args_are_placeholders(args: &[String]) -> bool { args.iter() .enumerate() @@ -325,37 +446,51 @@ fn remap_stmt_indices( stmt: &mut Stmt, local_base: usize, function_map: &HashMap, + flat_index_by_symbol: &HashMap, ) -> Result<(), SourcePathError> { match stmt { Stmt::Noop { .. } => {} Stmt::Let { index, expr, .. } => { *index = remap_local_index(*index, local_base)?; - remap_expr_indices(expr, local_base, function_map)?; + remap_expr_indices(expr, local_base, function_map, flat_index_by_symbol)?; } Stmt::Assign { index, expr, .. } => { *index = remap_local_index(*index, local_base)?; - remap_expr_indices(expr, local_base, function_map)?; + remap_expr_indices(expr, local_base, function_map, flat_index_by_symbol)?; } Stmt::ClosureLet { closure, .. } => { for (source_index, captured_slot) in &mut closure.capture_copies { *source_index = remap_local_index(*source_index, local_base)?; *captured_slot = remap_local_index(*captured_slot, local_base)?; } - remap_expr_indices(&mut closure.body, local_base, function_map)?; + remap_expr_indices( + &mut closure.body, + local_base, + function_map, + flat_index_by_symbol, + )?; } - Stmt::FuncDecl { index, .. } => { - *index = function_map.get(index).copied().ok_or_else(|| { - SourcePathError::Source(SourceError::Parse(ParseError { - span: None, - code: None, - line: 1, - message: "function index remap failed while merging imported modules" - .to_string(), - })) - })?; + Stmt::FuncDecl { + index, has_impl, .. + } => { + // Implementation-less declarations (import prelude stubs, extern + // prototypes) never enter the flat table and codegen ignores + // their index; only declarations with implementations are + // remapped to their symbol-owned flat entry. + if *has_impl { + *index = function_map.get(index).copied().ok_or_else(|| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: "function index remap failed while merging imported modules" + .to_string(), + })) + })?; + } } Stmt::Expr { expr, .. } => { - remap_expr_indices(expr, local_base, function_map)?; + remap_expr_indices(expr, local_base, function_map, flat_index_by_symbol)?; } Stmt::IfElse { condition, @@ -363,12 +498,12 @@ fn remap_stmt_indices( else_branch, .. } => { - remap_expr_indices(condition, local_base, function_map)?; + remap_expr_indices(condition, local_base, function_map, flat_index_by_symbol)?; for stmt in then_branch { - remap_stmt_indices(stmt, local_base, function_map)?; + remap_stmt_indices(stmt, local_base, function_map, flat_index_by_symbol)?; } for stmt in else_branch { - remap_stmt_indices(stmt, local_base, function_map)?; + remap_stmt_indices(stmt, local_base, function_map, flat_index_by_symbol)?; } } Stmt::For { @@ -378,19 +513,19 @@ fn remap_stmt_indices( body, .. } => { - remap_stmt_indices(init, local_base, function_map)?; - remap_expr_indices(condition, local_base, function_map)?; - remap_stmt_indices(post, local_base, function_map)?; + remap_stmt_indices(init, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(condition, local_base, function_map, flat_index_by_symbol)?; + remap_stmt_indices(post, local_base, function_map, flat_index_by_symbol)?; for stmt in body { - remap_stmt_indices(stmt, local_base, function_map)?; + remap_stmt_indices(stmt, local_base, function_map, flat_index_by_symbol)?; } } Stmt::While { condition, body, .. } => { - remap_expr_indices(condition, local_base, function_map)?; + remap_expr_indices(condition, local_base, function_map, flat_index_by_symbol)?; for stmt in body { - remap_stmt_indices(stmt, local_base, function_map)?; + remap_stmt_indices(stmt, local_base, function_map, flat_index_by_symbol)?; } } Stmt::Break { .. } | Stmt::Continue { .. } => {} @@ -405,6 +540,7 @@ fn remap_expr_indices( expr: &mut Expr, local_base: usize, function_map: &HashMap, + flat_index_by_symbol: &HashMap, ) -> Result<(), SourcePathError> { match expr { Expr::Null @@ -426,6 +562,29 @@ fn remap_expr_indices( }))); } } + Expr::ModuleFunctionRef(symbol, _) => { + let flat = flat_index_by_symbol.get(symbol).copied().ok_or_else(|| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: + "resolved module function value target is missing from the merged function table" + .to_string(), + })) + })?; + *expr = Expr::FunctionRef(flat, std::mem::take(&mut expr_type_args(expr))); + } + Expr::UnresolvedFunctionRef { .. } => { + // The loader resolves every function-value reference before + // merge; reaching the merge means resolution missed a site. + return Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: "unresolved function value reference reached the module merge".to_string(), + }))); + } Expr::Call(index, _, args) => { if let Some(remapped_index) = function_map.get(index).copied() { *index = remapped_index; @@ -439,8 +598,24 @@ fn remap_expr_indices( }))); } for arg in args { - remap_expr_indices(arg, local_base, function_map)?; + remap_expr_indices(arg, local_base, function_map, flat_index_by_symbol)?; + } + } + Expr::ModuleCall(symbol, type_args, args) => { + for arg in args.iter_mut() { + remap_expr_indices(arg, local_base, function_map, flat_index_by_symbol)?; } + let flat = flat_index_by_symbol.get(symbol).copied().ok_or_else(|| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: None, + code: None, + line: 1, + message: + "resolved module call target is missing from the merged function table" + .to_string(), + })) + })?; + *expr = Expr::Call(flat, std::mem::take(type_args), std::mem::take(args)); } Expr::OptionalGet { container, @@ -450,8 +625,8 @@ fn remap_expr_indices( } => { *container_slot = remap_local_index(*container_slot, local_base)?; *key_slot = remap_local_index(*key_slot, local_base)?; - remap_expr_indices(container, local_base, function_map)?; - remap_expr_indices(key, local_base, function_map)?; + remap_expr_indices(container, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(key, local_base, function_map, flat_index_by_symbol)?; } Expr::OptionUnwrapOr { value, @@ -459,13 +634,13 @@ fn remap_expr_indices( fallback, } => { *value_slot = remap_local_index(*value_slot, local_base)?; - remap_expr_indices(value, local_base, function_map)?; - remap_expr_indices(fallback, local_base, function_map)?; + remap_expr_indices(value, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(fallback, local_base, function_map, flat_index_by_symbol)?; } Expr::LocalCall(index, _, args) => { *index = remap_local_index(*index, local_base)?; for arg in args { - remap_expr_indices(arg, local_base, function_map)?; + remap_expr_indices(arg, local_base, function_map, flat_index_by_symbol)?; } } Expr::Closure(closure) => { @@ -476,7 +651,12 @@ fn remap_expr_indices( *source_index = remap_local_index(*source_index, local_base)?; *captured_slot = remap_local_index(*captured_slot, local_base)?; } - remap_expr_indices(&mut closure.body, local_base, function_map)?; + remap_expr_indices( + &mut closure.body, + local_base, + function_map, + flat_index_by_symbol, + )?; } Expr::ClosureCall(closure, args) => { for param_slot in &mut closure.param_slots { @@ -486,9 +666,14 @@ fn remap_expr_indices( *source_index = remap_local_index(*source_index, local_base)?; *captured_slot = remap_local_index(*captured_slot, local_base)?; } - remap_expr_indices(&mut closure.body, local_base, function_map)?; + remap_expr_indices( + &mut closure.body, + local_base, + function_map, + flat_index_by_symbol, + )?; for arg in args { - remap_expr_indices(arg, local_base, function_map)?; + remap_expr_indices(arg, local_base, function_map, flat_index_by_symbol)?; } } Expr::Add(lhs, rhs) @@ -501,15 +686,15 @@ fn remap_expr_indices( | Expr::Eq(lhs, rhs) | Expr::Lt(lhs, rhs) | Expr::Gt(lhs, rhs) => { - remap_expr_indices(lhs, local_base, function_map)?; - remap_expr_indices(rhs, local_base, function_map)?; + remap_expr_indices(lhs, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(rhs, local_base, function_map, flat_index_by_symbol)?; } Expr::Neg(inner) | Expr::Not(inner) | Expr::ToOwned(inner) | Expr::Borrow(inner) | Expr::BorrowMut(inner) => { - remap_expr_indices(inner, local_base, function_map)?; + remap_expr_indices(inner, local_base, function_map, flat_index_by_symbol)?; } Expr::Var(index) | Expr::MoveVar(index) => { *index = remap_local_index(*index, local_base)?; @@ -522,9 +707,9 @@ fn remap_expr_indices( then_expr, else_expr, } => { - remap_expr_indices(condition, local_base, function_map)?; - remap_expr_indices(then_expr, local_base, function_map)?; - remap_expr_indices(else_expr, local_base, function_map)?; + remap_expr_indices(condition, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(then_expr, local_base, function_map, flat_index_by_symbol)?; + remap_expr_indices(else_expr, local_base, function_map, flat_index_by_symbol)?; } Expr::Match { value_slot, @@ -535,21 +720,32 @@ fn remap_expr_indices( } => { *value_slot = remap_local_index(*value_slot, local_base)?; *result_slot = remap_local_index(*result_slot, local_base)?; - remap_expr_indices(value, local_base, function_map)?; + remap_expr_indices(value, local_base, function_map, flat_index_by_symbol)?; for (pattern, arm_expr) in arms { if let crate::compiler::ir::MatchPattern::SomeBinding(binding_slot) = pattern { *binding_slot = remap_local_index(*binding_slot, local_base)?; } - remap_expr_indices(arm_expr, local_base, function_map)?; + remap_expr_indices(arm_expr, local_base, function_map, flat_index_by_symbol)?; } - remap_expr_indices(default, local_base, function_map)?; + remap_expr_indices(default, local_base, function_map, flat_index_by_symbol)?; } Expr::Block { stmts, expr } => { for stmt in stmts { - remap_stmt_indices(stmt, local_base, function_map)?; + remap_stmt_indices(stmt, local_base, function_map, flat_index_by_symbol)?; } - remap_expr_indices(expr, local_base, function_map)?; + remap_expr_indices(expr, local_base, function_map, flat_index_by_symbol)?; } } Ok(()) } + +/// Borrow the type arguments of a resolved function-value node. +/// +/// Only used while converting a [`Expr::ModuleFunctionRef`] into a plain +/// [`Expr::FunctionRef`] in place. +fn expr_type_args(expr: &mut Expr) -> Vec { + match expr { + Expr::ModuleFunctionRef(_, type_args) => std::mem::take(type_args), + _ => Vec::new(), + } +} diff --git a/src/compiler/materialization.rs b/src/compiler/materialization.rs new file mode 100644 index 00000000..6bea071c --- /dev/null +++ b/src/compiler/materialization.rs @@ -0,0 +1,2311 @@ +//! Classify named script functions by whether they require a runtime +//! `Value::Callable` identity (materialization). +//! +//! The classification is keyed by the resolved flat function index assigned +//! during semantic module merge — never by source name — so same-named +//! declarations in independent modules classify independently. Codegen +//! consumes the classification when allocating hidden callable slots: +//! direct-only functions are lowered by the direct script-call opcode with +//! no hidden slot, and every function that needs materialization keeps a +//! hidden callable slot bound at frame entry. +//! +//! # Flow model +//! +//! The classification is computed by one authoritative IR visitor plus a +//! small monotone fixed-point dataflow: +//! +//! - The visitor handles every [`Expr`]/[`Stmt`] variant in exactly one +//! place and emits the semantic events: named function values +//! (`referenced_as_value`), statically resolved calls (`called_directly`), +//! per-frame slot-flow records, call sites with argument provenance, and +//! closure/capture boundaries. New IR variants must be added to the +//! visitor; there are no parallel walkers that can drift. +//! - Each execution frame (program root, named function body, closure body) +//! owns a slot-value flow: which named functions can occupy which local +//! slots, which slots are invoked through `Expr::LocalCall`, and which +//! call sites pass which argument provenance into which callee. +//! - A dynamic callable target is an invocation of a tracked slot +//! (`LocalCall`), or an argument that provably reaches an invoked +//! parameter slot of a known callee (named function or closure), tracked +//! transitively across frames. Passing a function value to an opaque +//! callee (host/builtin) or storing it in a container only marks +//! `referenced_as_value`; it never claims `dynamic_target_required` +//! without tracked flow to an invocation. This keeps +//! `requires_callable_slot` sound: every function value in the merged IR +//! originates from an `Expr::FunctionRef` node, so `referenced_as_value` +//! is always set where a dynamic target could be. +//! - Callable provenance that the flow record cannot enumerate — call +//! results, container reads, closures in value position, and slot values +//! that are not classified script functions — is tracked as *unknown* +//! per slot, and crosses the same alias, parameter, and capture edges as +//! tracked values. A dynamic invocation may claim that an argument +//! provably avoids a dynamic target (`Some(false)`) only when the callee +//! set is complete and every possible callee is known not to invoke the +//! parameter; unknown provenance keeps the propagation conservative. +//! - Captures copy values across frame boundaries (closures at creation +//! time, named functions at frame entry); the fixed point seeds capture +//! slots from the declaring frame's flow and translates invocations of a +//! captured slot back to its source slot, so a captured callable invoked +//! from inside a closure is attributed to the slot that held it. +//! - `runtime_self_required` only fires for recursion that executes in the +//! function's own frame: a statically resolved self-call in the function's +//! executable body (blocks, branches and loops are the same frame; closure +//! bodies are not), or a dynamic invocation of the function's own value +//! reachable from its frame (stored value invoked through `LocalCall`, or +//! the value passed to a callee that invokes its parameter). +//! +//! # Cost +//! +//! Classification runs once per compilation on the merged IR: one full IR +//! walk plus a monotone fixed point over frames, slots, and call sites. The +//! fixed point terminates because every lattice (slot values, invoked +//! slots, closure values, invoked parameters) only grows and is bounded by +//! the merged IR size; there is no O(function × IR) rescanning. This is +//! pure metadata production; codegen consumes `requires_callable_slot` +//! when counting callable slots and assigning hidden callable locals. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use super::ir::{ClosureExpr, Expr, FrontendIr, LocalSlot, Stmt}; + +/// Semantic facts about how one named script function is used across the +/// whole merged compilation. +/// +/// Compiler-internal metadata for the hidden callable slot allocation +/// decision; not part of the public API. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct CallableUseFacts { + /// The function is invoked through a statically resolved call site. + pub called_directly: bool, + /// The function value appears in the value domain (`Expr::FunctionRef`), + /// for example stored into a local, a map, or an array. + pub referenced_as_value: bool, + /// The function is exported under the `ExportedCallable` contract. + pub exported: bool, + /// The function captures an environment (declaration-time capture cells). + pub captures_environment: bool, + /// A dynamic call site can reach this function through tracked value + /// flow: the function value is stored into a slot that is invoked + /// (`Expr::LocalCall`), or it is passed as an argument to a parameter of + /// a known callee that is itself dynamically invoked. + pub dynamic_target_required: bool, + /// The function's own runtime callable identity must be bound at frame + /// entry (capturing or dynamic recursion path). + pub runtime_self_required: bool, +} + +impl CallableUseFacts { + /// Single decision derived from the semantic facts: does this function + /// need a hidden callable local slot? + /// + /// Plain direct calls — including non-capturing direct recursion — do + /// not require a slot; the direct script-call opcode lowers them by + /// prototype ID. Every other fact forces materialization into a hidden + /// callable slot that the runtime frame binds at entry. + pub fn requires_callable_slot(&self) -> bool { + self.referenced_as_value + || self.exported + || self.captures_environment + || self.dynamic_target_required + || self.runtime_self_required + } +} + +/// One observed classification entry for a resolved flat function identity, +/// produced by the production pipeline (parse -> module merge -> lifetime -> +/// classification -> Compiler) and attached to [`CompiledProgram`] so the +/// crate's unit tests can assert the facts the compiler actually received. +/// +/// Compiled into unit-test builds only; never part of the public API. +#[cfg(test)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CallableUseObservation { + /// Resolved flat function index (the classification key). + pub function_index: u16, + /// Merged declaration name, carried only so tests can identify the + /// entry; classification itself never keys by name. + pub name: String, + pub facts: CallableUseFacts, +} + +/// Classify every named script function in the merged IR. +/// +/// Facts are keyed by the resolved flat function index (the identity the +/// linker assigned through `SymbolId` remapping), never by source name. +pub(crate) fn classify_named_callables(ir: &FrontendIr) -> HashMap { + let mut classifier = Classifier::new(ir); + classifier.classify(ir); + classifier.facts +} + +/// Argument value provenance: the named function values an expression +/// directly evaluates to, the slots it reads, and whether it can also +/// evaluate to a callable whose identity is not tracked. +#[derive(Clone, Debug, Default)] +struct ArgFlow { + functions: BTreeSet, + slots: BTreeSet, + /// The expression can evaluate to a callable the flow record cannot + /// enumerate (call results, container reads, closures in value + /// position). A slot seeded with such a flow has an incomplete callee + /// set and may never be claimed to provably avoid invoking a parameter. + unknown: bool, +} + +/// A statically resolved call site with per-argument provenance. +#[derive(Clone, Debug)] +struct CallSite { + callee: u16, + args: Vec, +} + +/// A closure invocation with per-argument provenance. +#[derive(Clone, Debug)] +struct ClosureCallSite { + callee_frame: usize, + args: Vec, +} + +/// A dynamic invocation of a local slot with per-argument provenance. +#[derive(Clone, Debug)] +struct LocalCallSite { + slot: LocalSlot, + args: Vec, +} + +/// One execution frame's slot-flow records: the program root, a named +/// function body, or a closure body. Slot numbers are frame-relative; the +/// fixed point never mixes slots across frames except through the explicit +/// capture mappings. +#[derive(Default)] +struct FrameFlow { + /// The named function whose body this frame executes (`None` for the + /// program root and closure bodies). + function: Option, + /// Parameter slots of this frame (named functions and closures). + params: Vec, + /// Slots that directly received named function values. + seeds: HashMap>, + /// Slot aliases: `target` receives the values of every `source`. + aliases: HashMap>, + /// Slots invoked through `Expr::LocalCall`. + local_calls: BTreeSet, + /// LocalCall sites with arguments. + local_call_sites: Vec, + /// Named call sites. + call_sites: Vec, + /// Closure call sites. + closure_call_sites: Vec, + /// Closures created in this frame: (child frame, capture copies). + closures_created: Vec<(usize, Vec<(LocalSlot, LocalSlot)>)>, + /// Closure frames stored into slots (rebinds union). + closure_slots: HashMap>, + /// Slots that received values whose callable provenance is untracked + /// (call results, container reads): their callee sets are incomplete. + unknown: HashSet, +} + +/// The classification pass: one authoritative visitor plus a monotone +/// fixed-point dataflow over per-frame slot flows. +struct Classifier { + facts: HashMap, + frames: Vec, + /// Functions that call themselves from their own executable frame. + direct_self: HashSet, + /// Named-function body frame per function index. + function_frames: HashMap, + /// Captures per named function: (body frame, capture copies). + function_captures: HashMap)>, + /// Frame that declares each function (capture sources live there). + decl_frames: HashMap, + /// Fixed-point state: slot contents per frame. + values: Vec>>, + /// Fixed-point state: slots whose contents reach a dynamic callable + /// target. + invoked: Vec>, + /// Fixed-point state: closure frames per slot (alias-closed). + closure_values: Vec>>, + /// Fixed-point state: parameter slots that reach a dynamic callable + /// target. + dyn_params: Vec>, + /// Fixed-point state: slots with unknown callable provenance per frame. + unknown_values: Vec>, +} + +impl Classifier { + fn new(ir: &FrontendIr) -> Self { + let mut facts: HashMap = ir + .function_impls + .keys() + .map(|&index| (index, CallableUseFacts::default())) + .collect(); + for decl in &ir.functions { + if let Some(fact) = facts.get_mut(&decl.index) { + fact.exported = decl.exported; + } + } + for (index, function_impl) in &ir.function_impls { + if let Some(fact) = facts.get_mut(index) { + fact.captures_environment = !function_impl.capture_copies.is_empty(); + } + } + Self { + facts, + frames: vec![FrameFlow::default()], + direct_self: HashSet::new(), + function_frames: HashMap::new(), + function_captures: HashMap::new(), + decl_frames: HashMap::new(), + values: Vec::new(), + invoked: Vec::new(), + closure_values: Vec::new(), + dyn_params: Vec::new(), + unknown_values: Vec::new(), + } + } + + fn classify(&mut self, ir: &FrontendIr) { + // Create every named-function frame up front so call sites in any + // body can resolve callee frames regardless of walk order. + let mut function_impls = ir.function_impls.iter().collect::>(); + function_impls.sort_unstable_by_key(|(index, _)| **index); + for (index, function_impl) in &function_impls { + let frame = self.frames.len(); + self.frames.push(FrameFlow { + function: Some(**index), + params: function_impl.param_slots.clone(), + ..FrameFlow::default() + }); + self.function_frames.insert(**index, frame); + } + for (index, function_impl) in &function_impls { + let frame = self.function_frames[index]; + for stmt in &function_impl.body_stmts { + self.stmt(frame, stmt); + } + self.expr(frame, &function_impl.body_expr); + self.function_captures + .insert(**index, (frame, function_impl.capture_copies.clone())); + } + for stmt in &ir.stmts { + self.stmt(0, stmt); + } + self.fixed_point(); + self.attribute(); + } + + /// Authoritative statement visitor. Every [`Stmt`] variant is handled + /// here exactly once. + fn stmt(&mut self, frame: usize, stmt: &Stmt) { + match stmt { + Stmt::Noop { .. } | Stmt::Break { .. } | Stmt::Continue { .. } | Stmt::Drop { .. } => {} + Stmt::Let { index, expr, .. } | Stmt::Assign { index, expr, .. } => { + let mut flow = self.value_flow(expr); + if matches!(expr, Expr::Closure(_)) { + // A directly assigned closure is fully tracked through + // `closure_slots` below, so the slot's callee set stays + // complete. + flow.unknown = false; + } + self.seed_slot(frame, *index, &flow); + if let Expr::Closure(closure) = expr { + let child = self.closure(frame, closure); + self.frames[frame] + .closure_slots + .entry(*index) + .or_default() + .push(child); + } else { + self.expr(frame, expr); + } + } + Stmt::ClosureLet { closure, .. } => { + self.closure(frame, closure); + } + Stmt::FuncDecl { index, .. } => { + self.decl_frames.entry(*index).or_insert(frame); + } + Stmt::Expr { expr, .. } => self.expr(frame, expr), + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + self.expr(frame, condition); + for stmt in then_branch { + self.stmt(frame, stmt); + } + for stmt in else_branch { + self.stmt(frame, stmt); + } + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + self.stmt(frame, init); + self.expr(frame, condition); + self.stmt(frame, post); + for stmt in body { + self.stmt(frame, stmt); + } + } + Stmt::While { + condition, body, .. + } => { + self.expr(frame, condition); + for stmt in body { + self.stmt(frame, stmt); + } + } + } + } + + /// Authoritative expression visitor. Every [`Expr`] variant is handled + /// here exactly once; nested statements in blocks and closure bodies are + /// routed back through [`Self::stmt`] / [`Self::closure`]. + fn expr(&mut self, frame: usize, expr: &Expr) { + match expr { + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::String(_) + | Expr::Bytes(_) => {} + Expr::FunctionRef(index, _) => { + if let Some(fact) = self.facts.get_mut(index) { + fact.referenced_as_value = true; + } + } + // The classification runs on merged IR where module function + // references are already lowered to plain `Expr::FunctionRef` + // and `Expr::Call`; unresolved refs are rejected before this + // point. Only argument expressions can still be visited here. + Expr::ModuleFunctionRef(..) | Expr::UnresolvedFunctionRef { .. } => {} + Expr::ModuleCall(_, _, args) => { + for arg in args { + self.expr(frame, arg); + } + } + Expr::OptionalGet { container, key, .. } => { + self.expr(frame, container); + self.expr(frame, key); + } + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + self.expr(frame, value); + self.expr(frame, fallback); + } + Expr::Call(target, _, args) => { + if let Some(fact) = self.facts.get_mut(target) { + fact.called_directly = true; + if self.frames[frame].function == Some(*target) { + self.direct_self.insert(*target); + } + } + if self.function_frames.contains_key(target) { + let flows = args.iter().map(|arg| self.value_flow(arg)).collect(); + self.frames[frame].call_sites.push(CallSite { + callee: *target, + args: flows, + }); + } + for arg in args { + self.expr(frame, arg); + } + } + Expr::LocalCall(slot, _, args) => { + self.frames[frame].local_calls.insert(*slot); + if !args.is_empty() { + let flows = args.iter().map(|arg| self.value_flow(arg)).collect(); + self.frames[frame].local_call_sites.push(LocalCallSite { + slot: *slot, + args: flows, + }); + } + for arg in args { + self.expr(frame, arg); + } + } + Expr::Closure(closure) => { + self.closure(frame, closure); + } + Expr::ClosureCall(closure, args) => { + let callee_frame = self.closure(frame, closure); + let flows = args.iter().map(|arg| self.value_flow(arg)).collect(); + self.frames[frame].closure_call_sites.push(ClosureCallSite { + callee_frame, + args: flows, + }); + for arg in args { + self.expr(frame, arg); + } + } + Expr::Add(lhs, rhs) + | Expr::Sub(lhs, rhs) + | Expr::Mul(lhs, rhs) + | Expr::Div(lhs, rhs) + | Expr::Mod(lhs, rhs) + | Expr::Eq(lhs, rhs) + | Expr::Lt(lhs, rhs) + | Expr::Gt(lhs, rhs) + | Expr::And(lhs, rhs) + | Expr::Or(lhs, rhs) => { + self.expr(frame, lhs); + self.expr(frame, rhs); + } + Expr::Neg(inner) + | Expr::Not(inner) + | Expr::ToOwned(inner) + | Expr::Borrow(inner) + | Expr::BorrowMut(inner) => { + self.expr(frame, inner); + } + Expr::Var(_) | Expr::MoveVar(_) | Expr::MoveField { .. } | Expr::MoveIndex { .. } => {} + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + self.expr(frame, condition); + self.expr(frame, then_expr); + self.expr(frame, else_expr); + } + Expr::Match { + value, + arms, + default, + .. + } => { + self.expr(frame, value); + for (_, arm_expr) in arms { + self.expr(frame, arm_expr); + } + self.expr(frame, default); + } + Expr::Block { stmts, expr } => { + for stmt in stmts { + self.stmt(frame, stmt); + } + self.expr(frame, expr); + } + } + } + + /// Walk a closure body in its own frame and register the capture + /// boundary with the creating frame. Returns the child frame index. + fn closure(&mut self, frame: usize, closure: &ClosureExpr) -> usize { + let child = self.frames.len(); + self.frames.push(FrameFlow { + function: None, + params: closure.param_slots.clone(), + ..FrameFlow::default() + }); + self.expr(child, &closure.body); + self.frames[frame] + .closures_created + .push((child, closure.capture_copies.clone())); + child + } + + /// Top-level value provenance of an expression: the named function + /// values it directly evaluates to, the slots it reads, and whether it + /// can evaluate to a callable the flow record cannot enumerate. This is + /// a provenance query over the value-producing shapes only (function + /// values, slot reads, and union control flow); every other expression + /// yields no tracked provenance, and its nested function values are + /// still recorded by the visitor. + fn value_flow(&self, expr: &Expr) -> ArgFlow { + match expr { + Expr::FunctionRef(index, _) => ArgFlow { + functions: BTreeSet::from([*index]), + slots: BTreeSet::new(), + unknown: false, + }, + Expr::Borrow(inner) | Expr::BorrowMut(inner) | Expr::ToOwned(inner) => { + self.value_flow(inner) + } + Expr::Var(slot) | Expr::MoveVar(slot) => ArgFlow { + functions: BTreeSet::new(), + slots: BTreeSet::from([*slot]), + unknown: false, + }, + Expr::IfElse { + then_expr, + else_expr, + .. + } => { + let mut flow = self.value_flow(then_expr); + let other = self.value_flow(else_expr); + flow.functions.extend(other.functions); + flow.slots.extend(other.slots); + flow.unknown |= other.unknown; + flow + } + Expr::Match { arms, default, .. } => { + let mut flow = self.value_flow(default); + for (_, arm_expr) in arms { + let arm = self.value_flow(arm_expr); + flow.functions.extend(arm.functions); + flow.slots.extend(arm.slots); + flow.unknown |= arm.unknown; + } + flow + } + Expr::Block { stmts: _, expr } => self.value_flow(expr), + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + let mut flow = self.value_flow(value); + let other = self.value_flow(fallback); + flow.functions.extend(other.functions); + flow.slots.extend(other.slots); + flow.unknown |= other.unknown; + flow + } + // Call results, container reads, module references, moved + // container fields, and closures in value position can be + // callables whose identity the flow record cannot enumerate; a + // slot seeded with them has an incomplete callee set. Their + // nested function values are recorded by the visitor as value + // references. + Expr::ModuleCall(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Call(..) + | Expr::LocalCall(..) + | Expr::ClosureCall(..) + | Expr::OptionalGet { .. } + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } + | Expr::Closure(_) => ArgFlow { + functions: BTreeSet::new(), + slots: BTreeSet::new(), + unknown: true, + }, + // Literals and numeric/boolean operations cannot produce + // callable values. + _ => ArgFlow::default(), + } + } + + fn seed_slot(&mut self, frame: usize, slot: LocalSlot, flow: &ArgFlow) { + if flow.unknown { + self.frames[frame].unknown.insert(slot); + } + if !flow.functions.is_empty() { + self.frames[frame] + .seeds + .entry(slot) + .or_default() + .extend(flow.functions.iter().copied()); + } + if !flow.slots.is_empty() { + self.frames[frame] + .aliases + .entry(slot) + .or_default() + .extend(flow.slots.iter().copied()); + } + } + + /// Monotone fixed point over per-frame slot values, invoked slots, + /// closure values, unknown callable provenance, and dynamically invoked + /// parameters. Terminates because every lattice only grows. + fn fixed_point(&mut self) { + let frame_count = self.frames.len(); + self.values = (0..frame_count) + .map(|frame| self.frames[frame].seeds.clone()) + .collect(); + self.invoked = (0..frame_count) + .map(|frame| self.frames[frame].local_calls.clone()) + .collect(); + self.closure_values = (0..frame_count) + .map(|frame| self.frames[frame].closure_slots.clone()) + .collect(); + self.unknown_values = (0..frame_count) + .map(|frame| self.frames[frame].unknown.clone()) + .collect(); + self.dyn_params = vec![BTreeSet::new(); frame_count]; + + // Frame-derived records are immutable during the fixed point; clone + // them once so the iteration only mutates the growing lattices. + let aliases = self + .frames + .iter() + .map(|frame| frame.aliases.clone()) + .collect::>(); + let frame_params = self + .frames + .iter() + .map(|frame| frame.params.clone()) + .collect::>(); + let call_sites = self + .frames + .iter() + .map(|frame| frame.call_sites.clone()) + .collect::>(); + let closure_call_sites = self + .frames + .iter() + .map(|frame| frame.closure_call_sites.clone()) + .collect::>(); + let local_call_sites = self + .frames + .iter() + .map(|frame| frame.local_call_sites.clone()) + .collect::>(); + let closures_created = (0..frame_count) + .flat_map(|frame| { + self.frames[frame] + .closures_created + .iter() + .map(move |(child, captures)| (frame, *child, captures.clone())) + }) + .collect::>(); + let function_captures = self + .function_captures + .iter() + .map(|(index, (body_frame, captures))| (*index, *body_frame, captures.clone())) + .collect::>(); + + let mut changed = true; + while changed { + changed = false; + for frame in 0..frame_count { + // Intra-frame alias closure for slot values: values stored + // into an aliased slot flow into its targets. + for (target, sources) in &aliases[frame] { + let mut source_values = BTreeSet::new(); + for source in sources { + if let Some(values) = self.values[frame].get(source) { + source_values.extend(values.iter().copied()); + } + } + if !source_values.is_empty() { + let target_values = self.values[frame].entry(*target).or_default(); + for index in source_values { + if target_values.insert(index) { + changed = true; + } + } + } + } + // Reverse alias: a slot feeding an invoked slot is invoked + // too, so its contents reach the dynamic callable target. + for (target, sources) in &aliases[frame] { + if !self.invoked[frame].contains(target) { + continue; + } + for source in sources { + if self.invoked[frame].insert(*source) { + changed = true; + } + } + } + // Unknown callable provenance follows the same alias edges. + for (target, sources) in &aliases[frame] { + if sources + .iter() + .any(|source| self.unknown_values[frame].contains(source)) + && self.unknown_values[frame].insert(*target) + { + changed = true; + } + } + // Closure values follow the same alias edges. + for (target, sources) in &aliases[frame] { + let mut source_closures = Vec::new(); + for source in sources { + if let Some(closures) = self.closure_values[frame].get(source) { + source_closures.extend(closures.iter().copied()); + } + } + if !source_closures.is_empty() { + let target_closures = + self.closure_values[frame].entry(*target).or_default(); + for child in source_closures { + if !target_closures.contains(&child) { + target_closures.push(child); + changed = true; + } + } + } + } + // Invoked parameter slots reach a dynamic callable target. + for param in &frame_params[frame] { + if self.invoked[frame].contains(param) && self.dyn_params[frame].insert(*param) + { + changed = true; + } + } + // Named call sites: an invoked callee parameter makes the + // argument provenance invoked in this frame. + for site in &call_sites[frame] { + let Some(&callee_frame) = self.function_frames.get(&site.callee) else { + continue; + }; + for (arg_index, arg) in site.args.iter().enumerate() { + let Some(param) = frame_params[callee_frame].get(arg_index) else { + continue; + }; + if !self.dyn_params[callee_frame].contains(param) { + continue; + } + for slot in &arg.slots { + if self.invoked[frame].insert(*slot) { + changed = true; + } + } + for index in &arg.functions { + if let Some(fact) = self.facts.get_mut(index) { + fact.dynamic_target_required = true; + } + } + } + } + // Closure call sites: same rule, plus closure parameter value + // seeding so intra-closure aliasing sees the argument values. + for site in &closure_call_sites[frame] { + for (arg_index, arg) in site.args.iter().enumerate() { + let Some(param) = frame_params[site.callee_frame].get(arg_index) else { + continue; + }; + if self.dyn_params[site.callee_frame].contains(param) { + for slot in &arg.slots { + if self.invoked[frame].insert(*slot) { + changed = true; + } + } + for index in &arg.functions { + if let Some(fact) = self.facts.get_mut(index) { + fact.dynamic_target_required = true; + } + } + } + if self.seed_param_values(frame, site.callee_frame, *param, arg) { + changed = true; + } + } + } + // LocalCall sites: resolve statically known callees (named + // function values in the slot, closures stored into it); + // incomplete callee sets stay conservative. + for site in &local_call_sites[frame] { + let slot_values = self.values[frame] + .get(&site.slot) + .cloned() + .unwrap_or_default(); + let slot_closures = self + .closure_values + .get(frame) + .and_then(|closures| closures.get(&site.slot)) + .cloned() + .unwrap_or_default(); + for (arg_index, arg) in site.args.iter().enumerate() { + let known_invokes = callee_invokes_param( + site.slot, + frame, + &slot_values, + &slot_closures, + &self.function_frames, + &frame_params, + &self.dyn_params, + &self.unknown_values, + arg_index, + ); + if matches!(known_invokes, Some(false)) { + // Known callees never invoke this parameter and + // the callee set is complete: the argument does + // not reach a dynamic target. + continue; + } + for slot in &arg.slots { + if self.invoked[frame].insert(*slot) { + changed = true; + } + } + for index in &arg.functions { + if let Some(fact) = self.facts.get_mut(index) { + fact.dynamic_target_required = true; + } + } + for &callee_frame in &slot_closures { + if let Some(param) = frame_params[callee_frame].get(arg_index) + && self.seed_param_values(frame, callee_frame, *param, arg) + { + changed = true; + } + } + } + } + } + // Capture seeding across frame boundaries: closures copy values + // from their creating frame at creation time; named functions + // copy from their declaring frame at frame entry. An invocation + // of a captured slot inside the child frame also invokes the + // source slot in the creating frame (closure-escape dynamic + // paths), translated transitively by the fixed point. Unknown + // callable provenance crosses the same boundaries. + for (frame, child, captures) in &closures_created { + for (source, captured) in captures { + let source_values = + self.values[*frame].get(source).cloned().unwrap_or_default(); + if !source_values.is_empty() { + let target_values = self.values[*child].entry(*captured).or_default(); + for index in source_values { + if target_values.insert(index) { + changed = true; + } + } + } + if self.unknown_values[*frame].contains(source) + && self.unknown_values[*child].insert(*captured) + { + changed = true; + } + if self.invoked[*child].contains(captured) + && self.invoked[*frame].insert(*source) + { + changed = true; + } + } + } + for (index, body_frame, captures) in &function_captures { + let decl_frame = self.decl_frames.get(index).copied().unwrap_or(0); + for (source, captured) in captures { + let source_values = self.values[decl_frame] + .get(source) + .cloned() + .unwrap_or_default(); + if !source_values.is_empty() { + let target_values = self.values[*body_frame].entry(*captured).or_default(); + for value in source_values { + if target_values.insert(value) { + changed = true; + } + } + } + if self.unknown_values[decl_frame].contains(source) + && self.unknown_values[*body_frame].insert(*captured) + { + changed = true; + } + if self.invoked[*body_frame].contains(captured) + && self.invoked[decl_frame].insert(*source) + { + changed = true; + } + } + } + } + } + + /// Seed a callee's parameter slot with the argument's value provenance + /// (direct function values plus the caller slot contents) and unknown + /// callable provenance. Returns whether either lattice grew. + fn seed_param_values( + &mut self, + caller_frame: usize, + callee_frame: usize, + param: LocalSlot, + arg: &ArgFlow, + ) -> bool { + let mut changed = false; + if (arg.unknown + || arg + .slots + .iter() + .any(|slot| self.unknown_values[caller_frame].contains(slot))) + && self.unknown_values[callee_frame].insert(param) + { + changed = true; + } + let mut param_values = arg.functions.clone(); + for slot in &arg.slots { + if let Some(slot_values) = self.values[caller_frame].get(slot) { + param_values.extend(slot_values.iter().copied()); + } + } + if param_values.is_empty() { + return changed; + } + let target = self.values[callee_frame].entry(param).or_default(); + for index in param_values { + if target.insert(index) { + changed = true; + } + } + changed + } + + /// Derive the final facts from the fixed-point state. + fn attribute(&mut self) { + // Every slot whose contents reach a dynamic callable target marks + // those contents as dynamic targets. + let invoked = self.invoked.clone(); + for (frame, slots) in invoked.iter().enumerate() { + for slot in slots { + if let Some(indexes) = self.values[frame].get(slot) { + for index in indexes { + if let Some(fact) = self.facts.get_mut(index) { + fact.dynamic_target_required = true; + } + } + } + } + } + + // Frame-local self recursion: dynamic invocations of the function's + // own value reachable from its own frame — a stored value invoked + // through LocalCall, or the value passed to a callee that invokes + // its parameter. + let frame_params = self + .frames + .iter() + .map(|frame| frame.params.clone()) + .collect::>(); + let mut dynamic_self = HashSet::new(); + for (index, &(body_frame, _)) in &self.function_captures { + for slot in &self.invoked[body_frame] { + if self + .values + .get(body_frame) + .and_then(|values| values.get(slot)) + .is_some_and(|indexes| indexes.contains(index)) + { + dynamic_self.insert(*index); + } + } + for site in &self.frames[body_frame].call_sites { + let Some(&callee_frame) = self.function_frames.get(&site.callee) else { + continue; + }; + for (arg_index, arg) in site.args.iter().enumerate() { + if arg.functions.contains(index) + && frame_params[callee_frame] + .get(arg_index) + .is_some_and(|param| self.dyn_params[callee_frame].contains(param)) + { + dynamic_self.insert(*index); + } + } + } + for site in &self.frames[body_frame].closure_call_sites { + for (arg_index, arg) in site.args.iter().enumerate() { + if arg.functions.contains(index) + && frame_params[site.callee_frame] + .get(arg_index) + .is_some_and(|param| self.dyn_params[site.callee_frame].contains(param)) + { + dynamic_self.insert(*index); + } + } + } + for site in &self.frames[body_frame].local_call_sites { + let slot_values = self + .values + .get(body_frame) + .and_then(|values| values.get(&site.slot)) + .cloned() + .unwrap_or_default(); + let slot_closures = self + .closure_values + .get(body_frame) + .and_then(|closures| closures.get(&site.slot)) + .cloned() + .unwrap_or_default(); + for (arg_index, arg) in site.args.iter().enumerate() { + if !arg.functions.contains(index) { + continue; + } + let known_invokes = callee_invokes_param( + site.slot, + body_frame, + &slot_values, + &slot_closures, + &self.function_frames, + &frame_params, + &self.dyn_params, + &self.unknown_values, + arg_index, + ); + if !matches!(known_invokes, Some(false)) { + dynamic_self.insert(*index); + } + } + } + } + + for index in self.function_captures.keys().copied().collect::>() { + let self_recursive = self.direct_self.contains(&index) || dynamic_self.contains(&index); + if let Some(fact) = self.facts.get_mut(&index) { + fact.runtime_self_required = + self_recursive && (fact.captures_environment || fact.dynamic_target_required); + } + } + } +} + +/// Whether any statically known callee of a local slot (named function +/// values in the slot, closures stored into it) dynamically invokes argument +/// position `arg_index`. +/// +/// Returns `Some(true)` when at least one known callee invokes the +/// parameter, `Some(false)` when the callee set is complete and every +/// possible target provably does not invoke it, and `None` when the callee +/// set is incomplete — no callee is known, the slot also holds values with +/// untracked callable provenance (call results, container reads), or a slot +/// value is not a classified script function — so the caller must stay +/// conservative. +#[allow(clippy::too_many_arguments)] +fn callee_invokes_param( + slot: LocalSlot, + frame: usize, + slot_values: &BTreeSet, + slot_closures: &[usize], + function_frames: &HashMap, + frame_params: &[Vec], + dyn_params: &[BTreeSet], + unknown_values: &[HashSet], + arg_index: usize, +) -> Option { + if slot_values.is_empty() && slot_closures.is_empty() { + return None; + } + let mut any_invokes = false; + let mut all_known = true; + for &callee in slot_values { + let Some(&callee_frame) = function_frames.get(&callee) else { + // A callable value whose invocation behavior was not classified + // (e.g. a host/builtin function value): it cannot be proven not + // to invoke the parameter. + all_known = false; + continue; + }; + if frame_params[callee_frame] + .get(arg_index) + .is_some_and(|param| dyn_params[callee_frame].contains(param)) + { + any_invokes = true; + } + } + for &callee_frame in slot_closures { + if frame_params[callee_frame] + .get(arg_index) + .is_some_and(|param| dyn_params[callee_frame].contains(param)) + { + any_invokes = true; + } + } + if any_invokes { + return Some(true); + } + if !all_known || unknown_values[frame].contains(&slot) { + // Incomplete callee set: a possible callee with unknown invocation + // behavior keeps the propagation conservative. + return None; + } + Some(false) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::ValueType; + + use super::super::ir::{AssignmentKind, FunctionDecl, FunctionImpl, LocalSlot, MatchPattern}; + use super::super::linker::{ParsedUnit, merge_units}; + use super::super::modules::{ModuleId, SymbolId}; + use super::*; + + fn decl(index: u16, name: &str, exported: bool, symbol: Option) -> FunctionDecl { + FunctionDecl { + name: name.to_string(), + arity: 0, + index, + args: Vec::new(), + arg_schemas: Vec::new(), + return_schema: None, + type_params: Vec::new(), + exported, + return_type: ValueType::Int, + symbol, + } + } + + fn impl_with( + capture_copies: Vec<(LocalSlot, LocalSlot)>, + body_stmts: Vec, + body_expr: Expr, + ) -> FunctionImpl { + impl_with_params(Vec::new(), capture_copies, body_stmts, body_expr) + } + + fn impl_with_params( + param_slots: Vec, + capture_copies: Vec<(LocalSlot, LocalSlot)>, + body_stmts: Vec, + body_expr: Expr, + ) -> FunctionImpl { + FunctionImpl { + param_slots, + capture_copies, + body_stmts, + body_expr, + body_expr_line: 1, + } + } + + fn ir_with( + stmts: Vec, + functions: Vec, + function_impls: HashMap, + ) -> FrontendIr { + FrontendIr { + stmts, + locals: 0, + local_bindings: Vec::new(), + struct_schemas: HashMap::new(), + unknown_type_spans: Vec::new(), + functions, + function_impls, + stmt_sources: Vec::new(), + function_sources: HashMap::new(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), + } + } + + fn call(index: u16) -> Expr { + Expr::Call(index, Vec::new(), Vec::new()) + } + + fn func_decl_stmt(name: &str, index: u16) -> Stmt { + Stmt::FuncDecl { + name: name.to_string(), + index, + arity: 0, + args: Vec::new(), + exported: false, + has_impl: true, + line: 1, + } + } + + fn expr_stmt(expr: Expr) -> Stmt { + Stmt::Expr { expr, line: 1 } + } + + fn let_stmt(slot: LocalSlot, expr: Expr) -> Stmt { + Stmt::Let { + index: slot, + declared_schema: None, + expr, + line: 1, + } + } + + #[test] + fn materialization_direct_only_helper_needs_no_callable_slot() { + // `helper` is only ever invoked through statically resolved calls + // (from the root and from `caller`). No value reference, no export, + // no captures: it must not require a callable slot. + let helper_impl = impl_with(Vec::new(), Vec::new(), Expr::Int(1)); + let caller_impl = impl_with(Vec::new(), Vec::new(), call(0)); + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("caller", 1), + expr_stmt(call(0)), + expr_stmt(call(1)), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "caller", false, None), + ], + HashMap::from([(0, helper_impl), (1, caller_impl)]), + ); + + let facts = classify_named_callables(&ir); + let helper = facts[&0]; + assert!(helper.called_directly); + assert!(!helper.referenced_as_value); + assert!(!helper.exported); + assert!(!helper.captures_environment); + assert!(!helper.dynamic_target_required); + assert!(!helper.runtime_self_required); + assert!(!helper.requires_callable_slot()); + assert!(facts[&1].called_directly); + } + + #[test] + fn materialization_exported_direct_helper_requires_slot() { + let ir = ir_with( + vec![func_decl_stmt("helper", 0), expr_stmt(call(0))], + vec![decl(0, "helper", true, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.called_directly); + assert!(helper.exported); + assert!(helper.requires_callable_slot()); + } + + #[test] + fn materialization_value_referenced_local_requires_slot() { + // `let stored = helper;` puts the function value into the value + // domain even though nothing invokes it dynamically. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + expr_stmt(call(0)), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.called_directly); + assert!(helper.referenced_as_value); + assert!(!helper.dynamic_target_required); + assert!(helper.requires_callable_slot()); + } + + #[test] + fn materialization_container_storage_keeps_materialization_without_dynamic_target() { + // `list.push(helper)` flows the function value into a container + // through an opaque callee. The value is referenced and materialized, + // but no tracked value flow reaches an actual dynamic callable + // target, so `dynamic_target_required` stays false (F6 precision); + // materialization is preserved through `referenced_as_value`. + let push = Expr::Call( + 200, + Vec::new(), + vec![Expr::Var(11), Expr::FunctionRef(0, Vec::new())], + ); + let ir = ir_with( + vec![func_decl_stmt("helper", 0), let_stmt(12, push)], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.referenced_as_value); + assert!(!helper.dynamic_target_required); + assert!(helper.requires_callable_slot()); + } + + #[test] + fn materialization_locally_stored_value_called_dynamically_requires_dynamic_target() { + // The stored function value is invoked through `LocalCall` on the + // local that received it: a dynamic call site can target it. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.referenced_as_value); + assert!(helper.dynamic_target_required); + assert!(helper.requires_callable_slot()); + } + + #[test] + fn materialization_capturing_named_function_requires_environment() { + let ir = ir_with( + vec![func_decl_stmt("read", 0), expr_stmt(call(0))], + vec![decl(0, "read", false, None)], + HashMap::from([(0, impl_with(vec![(5, 7)], Vec::new(), Expr::Int(1)))]), + ); + + let read = classify_named_callables(&ir)[&0]; + assert!(read.called_directly); + assert!(read.captures_environment); + assert!(read.requires_callable_slot()); + } + + #[test] + fn materialization_noncapturing_direct_recursion_needs_no_runtime_self() { + // `fn count() { count() }` recurses through a statically resolved + // call and captures nothing: once the direct script-call opcode + // exists it needs neither a slot nor a runtime self identity. + let count_impl = impl_with(Vec::new(), Vec::new(), call(0)); + let ir = ir_with( + vec![func_decl_stmt("count", 0), expr_stmt(call(0))], + vec![decl(0, "count", false, None)], + HashMap::from([(0, count_impl)]), + ); + + let count = classify_named_callables(&ir)[&0]; + assert!(count.called_directly); + assert!(!count.captures_environment); + assert!(!count.runtime_self_required); + assert!(!count.requires_callable_slot()); + } + + #[test] + fn materialization_capturing_recursion_retains_runtime_self() { + // A capturing function that recurses directly needs its runtime self + // identity bound at frame entry to re-enter with its environment. + let ir = ir_with( + vec![func_decl_stmt("walk", 0), expr_stmt(call(0))], + vec![decl(0, "walk", false, None)], + HashMap::from([(0, impl_with(vec![(5, 7)], Vec::new(), call(0)))]), + ); + + let walk = classify_named_callables(&ir)[&0]; + assert!(walk.called_directly); + assert!(walk.captures_environment); + assert!(walk.runtime_self_required); + assert!(walk.requires_callable_slot()); + } + + #[test] + fn materialization_same_source_name_follows_resolved_identity() { + // Two functions both named `helper`, each with its own resolved + // identity: classification must follow the function index, never the + // shared source name. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("helper", 1), + expr_stmt(call(0)), + expr_stmt(call(1)), + ], + vec![ + decl(0, "helper", true, None), + decl(1, "helper", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert_eq!(facts.len(), 2); + let exported = facts[&0]; + let direct_only = facts[&1]; + assert!(exported.exported); + assert!(exported.requires_callable_slot()); + assert!(!direct_only.exported); + assert!(direct_only.called_directly); + assert!(!direct_only.requires_callable_slot()); + } + + #[test] + fn materialization_classification_survives_module_merge_remap() { + // Two independent modules each declare `fn helper` plus a `run` that + // calls it. The root calls its own exported `helper` directly and + // imports the sibling's `run` through a `ModuleCall`. After the real + // merge pipeline remaps unit indices and symbols to flat indices, + // classification must attribute facts to the resolved flat identity + // of each same-named function. + let sibling_symbol_helper = SymbolId { + module: ModuleId(2), + index: 0, + }; + let sibling_symbol_run = SymbolId { + module: ModuleId(2), + index: 1, + }; + let root_symbol_helper = SymbolId { + module: ModuleId(1), + index: 0, + }; + + let sibling_unit = ParsedUnit { + parsed: ir_with( + vec![func_decl_stmt("helper", 0), func_decl_stmt("run", 1)], + vec![ + decl(0, "helper", false, Some(sibling_symbol_helper)), + decl(1, "run", false, Some(sibling_symbol_run)), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(11))), + // `run` calls the sibling's own `helper` (unit index 0). + (1, impl_with(Vec::new(), Vec::new(), call(0))), + ]), + ), + scope_identity: Some("sibling__m2".to_string()), + source_name: "sibling.rss".to_string(), + module: ModuleId(2), + source_id: 1, + }; + + let root_unit = ParsedUnit { + parsed: ir_with( + vec![ + func_decl_stmt("helper", 0), + expr_stmt(call(0)), + // Imported call resolved to the sibling's `run` symbol. + expr_stmt(Expr::ModuleCall(sibling_symbol_run, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", true, Some(root_symbol_helper))], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(22)))]), + ), + scope_identity: None, + source_name: "main.rss".to_string(), + module: ModuleId(1), + source_id: 0, + }; + + let merged = + merge_units(vec![sibling_unit, root_unit]).expect("hand-built units must merge"); + + // Both same-named helpers survive as distinct flat entries; the + // assertions below key everything by resolved identity, never by the + // merged display name (a mangling policy change must not affect + // them). + assert_eq!(merged.functions.len(), 3); + assert_eq!(merged.function_impls.len(), 3); + + let facts = classify_named_callables(&merged); + assert_eq!(facts.len(), 3); + for index in merged.function_impls.keys() { + assert!(facts.contains_key(index), "every impl must be classified"); + } + + let flat_of = |symbol: SymbolId| -> u16 { + merged + .functions + .iter() + .find(|function| function.symbol == Some(symbol)) + .expect("symbol must have a flat entry") + .index + }; + + // The two same-named helpers must classify under distinct resolved + // flat identities. + let root_helper_index = flat_of(root_symbol_helper); + let sibling_helper_index = flat_of(sibling_symbol_helper); + assert_ne!( + root_helper_index, sibling_helper_index, + "same-named helpers must have distinct flat identities" + ); + + // The root's exported helper (flat index from symbol remap) keeps the + // exported fact and requires materialization. + let root_helper = facts[&root_helper_index]; + assert!(root_helper.called_directly); + assert!(root_helper.exported); + assert!(root_helper.requires_callable_slot()); + + // The sibling's direct-only helper (same source name, different + // identity) is called directly by its own `run` and needs no slot. + let sibling_helper = facts[&sibling_helper_index]; + assert!(sibling_helper.called_directly); + assert!(!sibling_helper.exported); + assert!(!sibling_helper.requires_callable_slot()); + + // The sibling's `run` is reached from the root through the + // symbol-resolved `ModuleCall` and is classified as called directly. + let sibling_run = facts[&flat_of(sibling_symbol_run)]; + assert!(sibling_run.called_directly); + assert!(!sibling_run.requires_callable_slot()); + } + + #[test] + fn materialization_requires_callable_slot_ignores_call_count_and_spelling() { + // The decision is a pure function of the semantic facts: many direct + // calls still need no slot, while a single value reference does. + let many_calls = ir_with( + vec![ + func_decl_stmt("hot", 0), + expr_stmt(call(0)), + expr_stmt(call(0)), + expr_stmt(call(0)), + ], + vec![decl(0, "hot", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + assert!(!classify_named_callables(&many_calls)[&0].requires_callable_slot()); + + let single_value_use = ir_with( + vec![ + func_decl_stmt("hot", 0), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + ], + vec![decl(0, "hot", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + assert!(classify_named_callables(&single_value_use)[&0].requires_callable_slot()); + } + + #[test] + fn materialization_facts_ignore_unrelated_statement_kinds() { + // Assignments and drops of ordinary values must not perturb the + // classification of an unrelated direct-only function. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + expr_stmt(call(0)), + let_stmt(10, Expr::Int(5)), + Stmt::Assign { + kind: AssignmentKind::Set, + index: 10, + expr: Expr::Int(6), + line: 1, + }, + Stmt::Drop { index: 10, line: 1 }, + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.called_directly); + assert!(!helper.referenced_as_value); + assert!(!helper.dynamic_target_required); + assert!(!helper.requires_callable_slot()); + } + + // --- F1: slot-to-slot / control-flow propagation of dynamic targets --- + + #[test] + fn materialization_slot_alias_chain_propagates_dynamic_target() { + // `let a = helper; let b = a; b();`: the function value flows through + // slot-to-slot aliasing before the dynamic invocation. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + let_stmt(11, Expr::Var(10)), + expr_stmt(Expr::LocalCall(11, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.referenced_as_value); + assert!(helper.dynamic_target_required); + } + + #[test] + fn materialization_move_var_alias_propagates_dynamic_target() { + // `let a = helper; let b = move a; b();`: moved values keep flowing. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + let_stmt(11, Expr::MoveVar(10)), + expr_stmt(Expr::LocalCall(11, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.dynamic_target_required); + } + + #[test] + fn materialization_ifelse_branch_values_propagate_dynamic_target() { + // `let x = if c { helper } else { other }; x();`: either branch value + // can reach the dynamic invocation. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("other", 1), + let_stmt( + 10, + Expr::IfElse { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(Expr::FunctionRef(0, Vec::new())), + else_expr: Box::new(Expr::FunctionRef(1, Vec::new())), + }, + ), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "other", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!(facts[&1].dynamic_target_required); + } + + #[test] + fn materialization_match_arm_values_propagate_dynamic_target() { + // `let x = match v { 1 => helper, _ => other }; x();` + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("other", 1), + let_stmt( + 10, + Expr::Match { + value_slot: 20, + result_slot: 21, + value: Box::new(Expr::Int(1)), + arms: vec![(MatchPattern::Int(1), Expr::FunctionRef(0, Vec::new()))], + default: Box::new(Expr::FunctionRef(1, Vec::new())), + }, + ), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "other", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!(facts[&1].dynamic_target_required); + } + + #[test] + fn materialization_block_result_propagates_dynamic_target() { + // `let x = { helper }; x();`: the block result value flows to the slot. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt( + 10, + Expr::Block { + stmts: Vec::new(), + expr: Box::new(Expr::FunctionRef(0, Vec::new())), + }, + ), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.dynamic_target_required); + } + + #[test] + fn materialization_rebind_alias_propagates_dynamic_target() { + // `let a = helper; a = other; let b = a; b();`: `b` aliases `a` after + // the rebind; the rebound value must still be attributed. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("other", 1), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + Stmt::Assign { + kind: AssignmentKind::Set, + index: 10, + expr: Expr::FunctionRef(1, Vec::new()), + line: 1, + }, + let_stmt(11, Expr::Var(10)), + expr_stmt(Expr::LocalCall(11, Vec::new(), Vec::new())), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "other", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].referenced_as_value); + assert!(facts[&1].dynamic_target_required); + } + + #[test] + fn materialization_closure_captured_callable_propagates_dynamic_target() { + // `let a = helper; let c = || { a() }; c();`: the closure captures slot + // `a` and invokes the captured value dynamically in its own frame. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + let_stmt( + 11, + Expr::Closure(ClosureExpr { + param_slots: Vec::new(), + capture_copies: vec![(10, 30)], + body: Box::new(Expr::LocalCall(30, Vec::new(), Vec::new())), + }), + ), + expr_stmt(Expr::LocalCall(11, Vec::new(), Vec::new())), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.dynamic_target_required); + } + + #[test] + fn materialization_named_function_capture_invocation_marks_dynamic_target() { + // `let a = helper; fn g() { a(); } g();`: the named function `g` + // captures slot `a` and invokes the captured value in its own frame. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("g", 1), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + expr_stmt(call(1)), + ], + vec![decl(0, "helper", false, None), decl(1, "g", false, None)], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + ( + 1, + impl_with( + vec![(10, 30)], + Vec::new(), + Expr::LocalCall(30, Vec::new(), Vec::new()), + ), + ), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + } + + // --- F2: frame-local self recursion --- + + #[test] + fn materialization_nested_closure_recursion_is_not_frame_local_self_recursion() { + // `fn f() { let c = || { f() }; c(); }` with captures: the call to `f` + // executes in the closure's frame, not in `f`'s own executable body, + // so it must not count as direct self-recursion. + let f_impl = impl_with( + vec![(5, 7)], + vec![ + let_stmt( + 10, + Expr::Closure(ClosureExpr { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body: Box::new(call(0)), + }), + ), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + Expr::Int(1), + ); + let ir = ir_with( + vec![func_decl_stmt("f", 0), expr_stmt(call(0))], + vec![decl(0, "f", false, None)], + HashMap::from([(0, f_impl)]), + ); + + let f = classify_named_callables(&ir)[&0]; + assert!(f.called_directly); + assert!(f.captures_environment); + assert!(!f.runtime_self_required); + assert!(f.requires_callable_slot()); + } + + #[test] + fn materialization_function_value_recursion_requires_runtime_self() { + // `fn f() { let g = f; g(); }`: the function's own value is invoked + // dynamically from within its own frame — a dynamic recursion path + // that must bind the runtime self identity. + let f_impl = impl_with( + Vec::new(), + vec![ + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + expr_stmt(Expr::LocalCall(10, Vec::new(), Vec::new())), + ], + Expr::Int(1), + ); + let ir = ir_with( + vec![func_decl_stmt("f", 0), expr_stmt(call(0))], + vec![decl(0, "f", false, None)], + HashMap::from([(0, f_impl)]), + ); + + let f = classify_named_callables(&ir)[&0]; + assert!(f.dynamic_target_required); + assert!(f.runtime_self_required); + } + + // --- F6: dynamic targets only through tracked invocation flow --- + + #[test] + fn materialization_opaque_callee_arg_keeps_materialization_without_dynamic_target() { + // `consume(helper)` where `consume` never invokes its parameter: the + // function value is referenced and materialized, but no tracked value + // flow reaches an actual dynamic callable target. + let consume_impl = impl_with_params(vec![10], Vec::new(), Vec::new(), Expr::Int(1)); + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("consume", 1), + expr_stmt(Expr::Call( + 1, + Vec::new(), + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "consume", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, consume_impl), + ]), + ); + + let facts = classify_named_callables(&ir); + let helper = facts[&0]; + assert!(helper.referenced_as_value); + assert!(!helper.dynamic_target_required); + assert!(helper.requires_callable_slot()); + } + + #[test] + fn materialization_invoking_callee_param_marks_dynamic_target() { + // `apply(f) { f() }` invoked as `apply(helper)`: the argument reaches + // a dynamic callable target inside the callee frame. + let apply_impl = impl_with_params( + vec![10], + Vec::new(), + Vec::new(), + Expr::LocalCall(10, Vec::new(), Vec::new()), + ); + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("apply", 1), + expr_stmt(Expr::Call( + 1, + Vec::new(), + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "apply", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, apply_impl), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].referenced_as_value); + assert!(facts[&0].dynamic_target_required); + assert!(!facts[&1].dynamic_target_required); + } + + #[test] + fn materialization_callee_param_alias_invocation_marks_dynamic_target() { + // `apply(f) { let g = f; g(); }`: the parameter reaches the dynamic + // invocation through an intra-frame alias. + let apply_impl = impl_with_params( + vec![10], + Vec::new(), + vec![let_stmt(11, Expr::Var(10))], + Expr::LocalCall(11, Vec::new(), Vec::new()), + ); + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("apply", 1), + expr_stmt(Expr::Call( + 1, + Vec::new(), + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "apply", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, apply_impl), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + } + + #[test] + fn materialization_transitive_callee_param_invocation_marks_dynamic_target() { + // `apply2(g) { apply(g) }` and `apply(f) { f() }`; `apply2(helper)`: + // the argument reaches the dynamic callable target through two frames. + let apply_impl = impl_with_params( + vec![20], + Vec::new(), + Vec::new(), + Expr::LocalCall(20, Vec::new(), Vec::new()), + ); + let apply2_impl = impl_with_params( + vec![10], + Vec::new(), + Vec::new(), + Expr::Call(1, Vec::new(), vec![Expr::Var(10)]), + ); + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("apply", 1), + func_decl_stmt("apply2", 2), + expr_stmt(Expr::Call( + 2, + Vec::new(), + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "apply", false, None), + decl(2, "apply2", false, None), + ], + HashMap::from([ + (0, impl_with(Vec::new(), Vec::new(), Expr::Int(1))), + (1, apply_impl), + (2, apply2_impl), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!(!facts[&1].dynamic_target_required); + } + + #[test] + fn materialization_closure_call_param_invocation_marks_dynamic_target() { + // Immediate closure invocation `(|f| f())(helper)`. + let closure = ClosureExpr { + param_slots: vec![30], + capture_copies: Vec::new(), + body: Box::new(Expr::LocalCall(30, Vec::new(), Vec::new())), + }; + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + expr_stmt(Expr::ClosureCall( + closure, + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.dynamic_target_required); + } + + #[test] + fn materialization_stored_closure_call_param_invocation_marks_dynamic_target() { + // `let apply = |f| f(); apply(helper);`: the closure is stored in a + // slot and later invoked through `LocalCall` with an argument that + // reaches its invoked parameter. + let closure = ClosureExpr { + param_slots: vec![30], + capture_copies: Vec::new(), + body: Box::new(Expr::LocalCall(30, Vec::new(), Vec::new())), + }; + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + let_stmt(10, Expr::Closure(closure)), + expr_stmt(Expr::LocalCall( + 10, + Vec::new(), + vec![Expr::FunctionRef(0, Vec::new())], + )), + ], + vec![decl(0, "helper", false, None)], + HashMap::from([(0, impl_with(Vec::new(), Vec::new(), Expr::Int(1)))]), + ); + + let helper = classify_named_callables(&ir)[&0]; + assert!(helper.dynamic_target_required); + } + + // --- F7: incomplete callee sets stay conservative (unknown provenance) --- + + #[test] + fn materialization_unknown_callee_provenance_keeps_conservative_propagation() { + // `let f = helper; f = get_cb(); f(cb);`: the slot holds a known + // named function that never invokes its parameter *and* a call + // result whose callable provenance is untracked. The callee set is + // incomplete, so `Some(false)` must not suppress the conservative + // propagation: the argument still reaches a dynamic callable target. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("cb", 1), + func_decl_stmt("get_cb", 2), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + Stmt::Assign { + kind: AssignmentKind::Set, + index: 10, + expr: Expr::Call(2, Vec::new(), Vec::new()), + line: 1, + }, + expr_stmt(Expr::LocalCall( + 10, + Vec::new(), + vec![Expr::FunctionRef(1, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "cb", false, None), + decl(2, "get_cb", false, None), + ], + HashMap::from([ + // `helper(x)` never invokes its parameter. + ( + 0, + impl_with_params(vec![40], Vec::new(), Vec::new(), Expr::Int(1)), + ), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + (2, impl_with(Vec::new(), Vec::new(), Expr::Int(3))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!( + facts[&1].dynamic_target_required, + "the argument must be conservatively treated as reaching a dynamic target" + ); + } + + #[test] + fn materialization_control_flow_closure_callee_keeps_conservative_propagation() { + // `let f = if c { |x| x() } else { helper }; f(cb);`: the closure + // branch is created but never recorded in the slot's closure set + // (only direct closure lets are), so the callee set is incomplete + // even though `helper` is a known non-invoking callee. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("cb", 1), + let_stmt( + 10, + Expr::IfElse { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(Expr::Closure(ClosureExpr { + param_slots: vec![30], + capture_copies: Vec::new(), + body: Box::new(Expr::LocalCall(30, Vec::new(), Vec::new())), + })), + else_expr: Box::new(Expr::FunctionRef(0, Vec::new())), + }, + ), + expr_stmt(Expr::LocalCall( + 10, + Vec::new(), + vec![Expr::FunctionRef(1, Vec::new())], + )), + ], + vec![decl(0, "helper", false, None), decl(1, "cb", false, None)], + HashMap::from([ + // `helper(x)` never invokes its parameter. + ( + 0, + impl_with_params(vec![40], Vec::new(), Vec::new(), Expr::Int(1)), + ), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!( + facts[&1].dynamic_target_required, + "the untracked closure branch must keep the propagation conservative" + ); + } + + #[test] + fn materialization_unknown_provenance_flows_through_closure_param_transitively() { + // `let apply = |f| { let g = f; g(cb) }; let a = helper; a = get_cb(); + // apply(a);`: the unknown provenance of `a` must flow through the + // closure's parameter slot and its alias `g`, so `cb` is + // conservatively treated as reaching a dynamic callable target even + // though the known callee `helper` never invokes its parameter. + let closure = ClosureExpr { + param_slots: vec![30], + capture_copies: Vec::new(), + body: Box::new(Expr::Block { + stmts: vec![let_stmt(31, Expr::Var(30))], + expr: Box::new(Expr::LocalCall( + 31, + Vec::new(), + vec![Expr::FunctionRef(1, Vec::new())], + )), + }), + }; + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("cb", 1), + func_decl_stmt("get_cb", 2), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + Stmt::Assign { + kind: AssignmentKind::Set, + index: 10, + expr: Expr::Call(2, Vec::new(), Vec::new()), + line: 1, + }, + let_stmt(11, Expr::Closure(closure)), + expr_stmt(Expr::LocalCall(11, Vec::new(), vec![Expr::Var(10)])), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "cb", false, None), + decl(2, "get_cb", false, None), + ], + HashMap::from([ + // `helper(x)` never invokes its parameter. + ( + 0, + impl_with_params(vec![40], Vec::new(), Vec::new(), Expr::Int(1)), + ), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + (2, impl_with(Vec::new(), Vec::new(), Expr::Int(3))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!( + facts[&1].dynamic_target_required, + "unknown provenance must flow through the closure parameter alias chain" + ); + } + + #[test] + fn materialization_unknown_provenance_flows_through_alias_chain_transitively() { + // `let a = helper; a = get_cb(); let b = a; let c = b; c(cb);`: the + // unknown provenance travels through two alias hops before the + // invocation, so the callee set of `c` is incomplete and `cb` must + // be conservatively marked as reaching a dynamic callable target. + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("cb", 1), + func_decl_stmt("get_cb", 2), + let_stmt(10, Expr::FunctionRef(0, Vec::new())), + Stmt::Assign { + kind: AssignmentKind::Set, + index: 10, + expr: Expr::Call(2, Vec::new(), Vec::new()), + line: 1, + }, + let_stmt(11, Expr::Var(10)), + let_stmt(12, Expr::Var(11)), + expr_stmt(Expr::LocalCall( + 12, + Vec::new(), + vec![Expr::FunctionRef(1, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "cb", false, None), + decl(2, "get_cb", false, None), + ], + HashMap::from([ + // `helper(x)` never invokes its parameter. + ( + 0, + impl_with_params(vec![40], Vec::new(), Vec::new(), Expr::Int(1)), + ), + (1, impl_with(Vec::new(), Vec::new(), Expr::Int(2))), + (2, impl_with(Vec::new(), Vec::new(), Expr::Int(3))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!( + facts[&1].dynamic_target_required, + "unknown provenance must flow through the alias chain to the invocation" + ); + } + + #[test] + fn materialization_complete_control_flow_callee_set_keeps_precision() { + // `let f = if c { helper } else { other }; f(cb);`: every branch is + // a tracked named function and neither invokes its parameter, so the + // callee set is complete and `Some(false)` legitimately suppresses + // the propagation (precision guard: the soundness fix must not + // degrade fully-tracked control flow). + let ir = ir_with( + vec![ + func_decl_stmt("helper", 0), + func_decl_stmt("other", 1), + func_decl_stmt("cb", 2), + let_stmt( + 10, + Expr::IfElse { + condition: Box::new(Expr::Bool(true)), + then_expr: Box::new(Expr::FunctionRef(0, Vec::new())), + else_expr: Box::new(Expr::FunctionRef(1, Vec::new())), + }, + ), + expr_stmt(Expr::LocalCall( + 10, + Vec::new(), + vec![Expr::FunctionRef(2, Vec::new())], + )), + ], + vec![ + decl(0, "helper", false, None), + decl(1, "other", false, None), + decl(2, "cb", false, None), + ], + HashMap::from([ + ( + 0, + impl_with_params(vec![40], Vec::new(), Vec::new(), Expr::Int(1)), + ), + ( + 1, + impl_with_params(vec![41], Vec::new(), Vec::new(), Expr::Int(2)), + ), + (2, impl_with(Vec::new(), Vec::new(), Expr::Int(3))), + ]), + ); + + let facts = classify_named_callables(&ir); + assert!(facts[&0].dynamic_target_required); + assert!(facts[&1].dynamic_target_required); + assert!( + !facts[&2].dynamic_target_required, + "a complete callee set of non-invoking functions must suppress propagation" + ); + } +} diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 3b52d54b..1f0f0a74 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -14,12 +14,16 @@ mod frontends; pub mod ir; mod lifetime; mod linker; +mod materialization; +mod modules; mod parser; mod pipeline; mod source_loader; pub mod source_map; mod typing; +#[cfg(test)] +use self::materialization::CallableUseObservation; use self::source_map::{SourceMap, Span}; pub use self::codegen::Compiler; @@ -31,6 +35,10 @@ pub use self::ir::{ AssignmentKind, ClosureExpr, Expr, FrontendIr, FunctionDecl, FunctionImpl, FunctionParam, LocalIrBuilder, LocalSlot, MatchPattern, MatchTypePattern, Stmt, StructDecl, TypeSchema, }; +pub use self::modules::{ + DeclSymbol, ExportEntry, ImportTargetKind, ImportedBinding, ModuleGraph, ModuleId, ModuleNode, + ResolvedImport, SymbolId, UseDecl, UsePathSegment, +}; pub use self::parser::ParserDialect; pub use self::pipeline::{ InferredLocalTypeHint, UnknownInferredLocal, collect_inferred_local_type_hints, @@ -53,6 +61,15 @@ pub enum CompileError { CallableUsedAsValue, NonCallableLocal(LocalSlot), LocalSlotOverflow(LocalSlot), + /// The aggregate frame-local count (data slots plus materialized callable + /// slots) exceeds what the short bytecode operands can address. Carries + /// the real counts so the diagnostic is actionable instead of a sentinel. + FrameLocalLimitExceeded { + data_slots: usize, + callable_slots: usize, + total_slots: usize, + max_slots: usize, + }, CallableArityMismatch { expected: usize, got: usize, @@ -90,6 +107,10 @@ pub enum CompileError { source_name: Option, detail: String, }, + /// Internal error: a symbol-resolved module call or function value + /// survived unit merge and reached codegen, where flat function indices + /// are the only valid call targets. + UnresolvedModuleCall, } impl CompileError { @@ -146,6 +167,14 @@ impl CompileError { CompileError::LocalSlotOverflow(slot) => { format!("local slot {slot} exceeds the supported bytecode encoding") } + CompileError::FrameLocalLimitExceeded { + data_slots, + callable_slots, + total_slots, + max_slots, + } => format!( + "frame requires {total_slots} local slots ({data_slots} data + {callable_slots} callable); short bytecode supports {max_slots}" + ), CompileError::CallableArityMismatch { expected, got } => { format!("callable arity mismatch: expected {expected}, got {got}") } @@ -160,6 +189,9 @@ impl CompileError { CompileError::InvalidFieldAccess { detail, .. } => detail.clone(), CompileError::FunctionParameterTypeConflict { detail, .. } => detail.clone(), CompileError::StrictTypingRequired { detail, .. } => detail.clone(), + CompileError::UnresolvedModuleCall => { + "internal compiler error: unresolved module call reached codegen".to_string() + } } } } @@ -272,6 +304,25 @@ pub enum SourcePathError { message: String, }, Source(SourceError), + /// A source error plus the compilation-wide [`SourceMap`] that resolves + /// every span it carries (milestone 5). Produced by the module-loading + /// compile entry points; spans reference the semantic module graph's + /// `SourceId` space, so rendering against this map always reads from the + /// owning source. `Display` delegates to the inner error. + SourceWithMap { + error: SourceError, + sources: SourceMap, + }, +} + +impl SourcePathError { + /// The compilation-wide source map carried with this error, if any. + pub fn sources(&self) -> Option<&SourceMap> { + match self { + SourcePathError::SourceWithMap { sources, .. } => Some(sources), + _ => None, + } + } } impl fmt::Display for SourcePathError { @@ -309,6 +360,7 @@ impl fmt::Display for SourcePathError { message ), SourcePathError::Source(err) => write!(f, "{err}"), + SourcePathError::SourceWithMap { error, .. } => write!(f, "{error}"), } } } @@ -366,6 +418,12 @@ pub struct SharedParserOptions { pub allow_implicit_externs: bool, pub allow_implicit_semicolons: bool, pub enforce_mutable_bindings: bool, + /// Import-scan mode: used by the source loader's discovery parse. The + /// parser tolerates calls to not-yet-declared imported functions + /// (`allow_implicit_externs`) and records host aliases for multi-segment + /// file-module paths so namespace calls parse during the scan; the + /// resulting IR is discarded after `use` declarations are extracted. + pub import_scan_mode: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -440,6 +498,12 @@ pub struct CompiledProgram { pub program: Program, pub locals: usize, pub functions: Vec, + /// Milestone-5 callable-use classification observed through the + /// production pipeline, keyed by resolved flat function index and + /// sorted by index. Test-only observation compiled into the crate's + /// unit-test builds only; never part of the public API. + #[cfg(test)] + pub(crate) callable_use_facts: Vec, } impl CompiledProgram { diff --git a/src/compiler/modules.rs b/src/compiler/modules.rs new file mode 100644 index 00000000..efe8ea97 --- /dev/null +++ b/src/compiler/modules.rs @@ -0,0 +1,1046 @@ +//! Compiler-owned module identities and the semantic module graph. +//! +//! Milestones 1-6 of the semantic module system: every source that takes +//! part in a compilation is assigned a deterministic [`ModuleId`] and +//! [`SourceId`], `use` directives are parsed into structured [`UseDecl`] +//! nodes with spans and clauses, the source loader records resolved import +//! edges in a [`ModuleGraph`], and every declaration receives a +//! [`SymbolId`] owned by its module alongside an explicit public export +//! table and a separate imported-binding table. Identities never depend on +//! a file stem alone: two modules with the same basename in different +//! directories are distinct nodes, and re-visiting the same module identity +//! reuses the same node. +//! +//! Since milestone 6 the semantic graph is the *sole* file-module path: the +//! textual import rewriting, the synthetic imported-function prelude, and +//! the prelude line-map remapping are removed. Call sites resolve to +//! [`SymbolId`]s in the source loader and the linker merges units by symbol +//! identity, applying deterministic flat-boundary mangling only at the final +//! bytecode boundary. +//! +//! [`SourceId`] here is the module graph's own identity space, distinct from +//! `source_map::SourceId` (which is assigned per-unit by ad hoc `SourceMap` +//! instances). Milestone 5 reconciles the two spaces: every module's raw +//! text is registered in the compilation-wide `SourceMap` at its graph +//! `SourceId`, so spans survive unit merge with their owning source. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use super::SourcePathError; +use super::frontends::{is_ident_continue, is_ident_start}; +use super::source_loader::ImportClause; +use super::source_map::Span; + +/// Deterministic identity of one module within a single compilation. +/// +/// Assigned in discovery order: the root unit is always `ModuleId(0)`, and +/// every discovered module gets the next unused id the first time its +/// canonical identity is registered. Re-importing the same module yields the +/// same id; two modules that merely share a file stem are distinct. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ModuleId(pub u32); + +/// Deterministic identity of one parsed source text within a compilation. +/// +/// Distinct from `source_map::SourceId`: the module graph hands out its own +/// monotonic ids so that graph edges can reference sources without depending +/// on per-unit `SourceMap` construction order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SourceId(pub u32); + +/// Deterministic identity of one declaration within a compilation. +/// +/// Composed of the owning [`ModuleId`] and a module-local index, so two +/// same-named declarations in independent modules never collide. Milestone 3 +/// assigns symbol ids to declarations; the type is defined here so the whole +/// identity surface lands in one place. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct SymbolId { + pub module: ModuleId, + pub index: u32, +} + +/// One segment of a structured `use` path. +/// +/// `self`/`super` are only classified as qualifiers while they lead the path, +/// mirroring the legacy line-based resolver: a `self` appearing mid-path is a +/// literal file segment (e.g. `use a::self::b;` resolves `a/self/b.rss`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UsePathSegment { + Self_, + Super, + Ident(String), +} + +/// A structured `use` directive parsed from RustScript source. +/// +/// Carries the full path (including `self`/`super` qualifiers), the import +/// clause, the exact source span of the directive, and the directive line. +/// The source loader consumes these nodes for discovery instead of treating +/// line-prefix stripping as the authoritative import parser. +#[derive(Clone, Debug)] +pub struct UseDecl { + pub path: Vec, + pub clause: ImportClause, + pub span: Span, + pub line: usize, +} + +/// Classification of one resolved import edge. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImportTargetKind { + /// A RustScript file module loaded from disk or an override. + FileModule, + /// A virtual host namespace resolved on the dedicated host path. + HostNamespace, + /// A builtin namespace such as `io`, `json`, or `re`. + BuiltinNamespace, +} + +/// A resolved import edge inside a [`ModuleGraph`]. +/// +/// `target` is `Some(ModuleId)` once the destination module node is known +/// (`FileModule` edges), and `None` for host/builtin namespaces that stay on +/// their dedicated resolution paths. +#[derive(Clone, Debug)] +pub struct ResolvedImport { + pub kind: ImportTargetKind, + /// Normalized module specifier (e.g. `./nested.rss`). + pub spec: String, + pub clause: ImportClause, + pub span: Span, + pub line: usize, + pub target: Option, +} + +/// One declaration owned by a module, with its deterministic [`SymbolId`]. +/// +/// Milestone 3: every declaration in a module receives a symbol whose +/// `module` is the owning [`ModuleId`] and whose `index` is the declaration's +/// position in the module's declaration table. Two same-named declarations in +/// independent modules therefore never share a symbol. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeclSymbol { + pub symbol: SymbolId, + pub name: String, + /// `true` when the declaration is marked `pub` and appears in the module's + /// public export table. + pub public: bool, +} + +/// One entry of a module's public export table. +/// +/// The table is populated exclusively from local public declarations: +/// imported bindings never appear here, so re-exporting another module's +/// functions requires an explicit mechanism and there is no implicit +/// transitive re-export through the graph. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExportEntry { + pub name: String, + pub symbol: SymbolId, +} + +/// A binding introduced into a module by a resolved import edge. +/// +/// Imported bindings are tracked separately from local declarations: they are +/// never part of [`ModuleNode::declarations`] and never enter +/// [`ModuleNode::exports`]. `local_name` is the name the importing module +/// binds (`as` alias for named imports, or the source name otherwise); +/// `source_name` is the declaration's name in the source module. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImportedBinding { + pub local_name: String, + pub source_module: ModuleId, + pub source_symbol: SymbolId, + pub source_name: String, +} + +/// One node of the [`ModuleGraph`]: a module, its resolved imports, and its +/// milestone-3 declaration/export/imported-binding tables. +/// +/// Milestone 1-2 fills `imports` during source-loader discovery; milestone 3 +/// fills `declarations`, `exports`, and `imported_bindings` once each module's +/// unit is parsed. +#[derive(Clone, Debug)] +pub struct ModuleNode { + pub module: ModuleId, + pub source: SourceId, + /// Canonical disk identity (or normalized virtual identity) of the module. + pub identity: PathBuf, + /// Display name used in diagnostics. + pub source_name: String, + pub imports: Vec, + /// Local declarations in source order, each with its owned symbol. + pub declarations: Vec, + /// Public export table: local `pub` declarations only. + pub exports: Vec, + /// Bindings introduced by import edges, separate from local declarations. + pub imported_bindings: Vec, +} + +/// The module graph for one compilation. +/// +/// Nodes are registered in deterministic discovery order; the first node is +/// always the root unit. `by_identity` guarantees that the same canonical +/// module identity maps to exactly one node, so modules with identical file +/// stems in different directories stay distinct while lexically equivalent +/// paths collapse. +#[derive(Default)] +pub struct ModuleGraph { + nodes: Vec, + by_identity: HashMap, + next_source: u32, +} + +impl ModuleGraph { + pub fn new() -> Self { + Self::default() + } + + /// Register a module node for `identity`, or return the existing node id. + /// + /// The first call for the root unit yields `ModuleId(0)` / `SourceId(0)`; + /// subsequent first-time registrations receive the next unused ids in + /// call order. + pub fn add_node( + &mut self, + identity: PathBuf, + source_name: String, + imports: Vec, + ) -> ModuleId { + if let Some(existing) = self.by_identity.get(&identity) { + return *existing; + } + let module = ModuleId(u32::try_from(self.nodes.len()).unwrap_or(u32::MAX)); + let source = SourceId(self.next_source); + self.next_source = self.next_source.saturating_add(1); + self.by_identity.insert(identity.clone(), module); + self.nodes.push(ModuleNode { + module, + source, + identity, + source_name, + imports, + declarations: Vec::new(), + exports: Vec::new(), + imported_bindings: Vec::new(), + }); + module + } + + pub fn node(&self, module: ModuleId) -> Option<&ModuleNode> { + self.nodes.get(module.0 as usize) + } + + pub fn nodes(&self) -> &[ModuleNode] { + &self.nodes + } + + pub fn len(&self) -> usize { + self.nodes.len() + } + + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + pub fn module_id_for_identity(&self, identity: &Path) -> Option { + self.by_identity.get(identity).copied() + } + + /// Append one resolved import edge to a module node. + pub fn add_import(&mut self, module: ModuleId, import: ResolvedImport) { + if let Some(node) = self.nodes.get_mut(module.0 as usize) { + node.imports.push(import); + } + } + + /// Register one local declaration of `module` and return its symbol. + /// + /// Symbols are deterministic: the first declaration of a module is + /// `SymbolId { module, index: 0 }`, the second `index: 1`, and so on in + /// registration order. Public declarations are appended to the module's + /// export table at the same time; imported bindings never enter it. + /// + /// Errors when the module already declares the same name (the parser + /// normally reports this first with a source line) or when the name is + /// already bound by an import. + pub fn add_declaration( + &mut self, + module: ModuleId, + name: &str, + public: bool, + ) -> Result { + let node = self.node_mut(module)?; + if node.declarations.iter().any(|decl| decl.name == name) { + return Err(format!( + "duplicate local declaration '{name}' in module '{}'", + node.source_name + )); + } + if node + .imported_bindings + .iter() + .any(|binding| binding.local_name == name) + { + return Err(format!( + "local declaration '{name}' conflicts with an imported binding in module '{}'", + node.source_name + )); + } + let index = u32::try_from(node.declarations.len()) + .map_err(|_| format!("too many declarations in module '{}'", node.source_name))?; + let symbol = SymbolId { module, index }; + node.declarations.push(DeclSymbol { + symbol, + name: name.to_string(), + public, + }); + if public { + node.exports.push(ExportEntry { + name: name.to_string(), + symbol, + }); + } + Ok(symbol) + } + + /// Record one binding introduced by an import edge of `module`. + /// + /// Imported bindings stay out of `declarations` and `exports`: nothing in + /// the graph re-exports them implicitly. Binding the same local name from + /// several modules is recorded as several bindings (the legacy pipeline + /// merges such imports by name; milestone 4 resolves them by symbol), but + /// a binding that clashes with a local declaration is rejected. + pub fn add_imported_binding( + &mut self, + module: ModuleId, + binding: ImportedBinding, + ) -> Result<(), String> { + let node = self.node_mut(module)?; + if node + .declarations + .iter() + .any(|decl| decl.name == binding.local_name) + { + return Err(format!( + "imported binding '{}' conflicts with a local declaration in module '{}'", + binding.local_name, node.source_name + )); + } + node.imported_bindings.push(binding); + Ok(()) + } + + pub fn declaration(&self, module: ModuleId, name: &str) -> Option<&DeclSymbol> { + self.node(module)? + .declarations + .iter() + .find(|decl| decl.name == name) + } + + pub fn declaration_symbol(&self, module: ModuleId, name: &str) -> Option { + self.declaration(module, name).map(|decl| decl.symbol) + } + + pub fn export(&self, module: ModuleId, name: &str) -> Option<&ExportEntry> { + self.node(module)? + .exports + .iter() + .find(|entry| entry.name == name) + } + + pub fn symbol_for_export(&self, module: ModuleId, name: &str) -> Option { + self.export(module, name).map(|entry| entry.symbol) + } + + pub fn imported_binding(&self, module: ModuleId, name: &str) -> Option<&ImportedBinding> { + self.node(module)? + .imported_bindings + .iter() + .find(|binding| binding.local_name == name) + } + + fn node_mut(&mut self, module: ModuleId) -> Result<&mut ModuleNode, String> { + self.nodes + .get_mut(module.0 as usize) + .ok_or_else(|| format!("unknown module id {} in module graph", module.0)) + } +} + +fn is_valid_ident_segment(input: &str) -> bool { + let mut chars = input.chars(); + let Some(first) = chars.next() else { + return false; + }; + is_ident_start(first) && chars.all(is_ident_continue) +} + +/// Convert a structured `use` path into a module specifier. +/// +/// Replicates the legacy resolver's semantics from structured segments: +/// leading `self`/`super` qualifiers become `./`/`../` prefixes, a leading +/// `crate` is rejected, remaining segments join into a relative path, and the +/// result is normalized to a `.rss` specifier. +pub(super) fn use_path_to_spec( + path: &Path, + line: usize, + segments: &[UsePathSegment], +) -> Result { + let invalid = |message: &str| SourcePathError::InvalidImportSyntax { + path: path.to_path_buf(), + line, + message: message.to_string(), + }; + if segments.is_empty() { + return Err(invalid("expected module path after 'use'")); + } + + let mut path_prefix = PathBuf::new(); + let mut cursor = 0usize; + let mut explicit_self = false; + while cursor < segments.len() { + match &segments[cursor] { + UsePathSegment::Self_ => { + explicit_self = true; + cursor += 1; + } + UsePathSegment::Super => { + path_prefix.push(".."); + cursor += 1; + } + UsePathSegment::Ident(name) if name == "crate" => { + return Err(invalid( + "crate:: paths are not supported; use relative module paths", + )); + } + UsePathSegment::Ident(_) => break, + } + } + if cursor >= segments.len() { + return Err(invalid("expected module name after path qualifiers")); + } + + for segment in &segments[cursor..] { + match segment { + UsePathSegment::Ident(name) => { + if !is_valid_ident_segment(name) { + return Err(invalid(&format!( + "invalid module path segment '{name}' in use directive" + ))); + } + path_prefix.push(name); + } + // Mid-path qualifier words are literal file segments, mirroring + // the legacy line-based resolver. + UsePathSegment::Self_ => path_prefix.push("self"), + UsePathSegment::Super => path_prefix.push("super"), + } + } + + let mut spec = path_prefix.to_string_lossy().replace('\\', "/"); + if spec.is_empty() { + return Err(invalid("expected module path after 'use'")); + } + if explicit_self && !spec.starts_with("../") { + spec = format!("./{spec}"); + } + if !spec.ends_with(".rss") { + spec.push_str(".rss"); + } + Ok(spec) +} + +#[cfg(test)] +mod tests { + use super::{ + ImportTargetKind, ImportedBinding, ModuleGraph, ModuleId, ResolvedImport, SourceId, + SymbolId, UsePathSegment, use_path_to_spec, + }; + use crate::compiler::source_loader::ImportClause; + use crate::compiler::source_map::Span; + use std::path::PathBuf; + + fn ident(name: &str) -> UsePathSegment { + UsePathSegment::Ident(name.to_string()) + } + + #[test] + fn use_path_to_spec_plain_module_path() { + let path = PathBuf::from("/root/main.rss"); + let spec = use_path_to_spec(&path, 1, &[ident("helpers")]).expect("spec should resolve"); + assert_eq!(spec, "helpers.rss"); + let spec = + use_path_to_spec(&path, 1, &[ident("a"), ident("b")]).expect("spec should resolve"); + assert_eq!(spec, "a/b.rss"); + } + + #[test] + fn use_path_to_spec_self_and_super_qualifiers() { + let path = PathBuf::from("/root/pkg/main.rss"); + let spec = use_path_to_spec(&path, 1, &[UsePathSegment::Self_, ident("nested")]) + .expect("spec should resolve"); + assert_eq!(spec, "./nested.rss"); + let spec = use_path_to_spec(&path, 1, &[UsePathSegment::Super, ident("shared")]) + .expect("spec should resolve"); + assert_eq!(spec, "../shared.rss"); + let spec = use_path_to_spec( + &path, + 1, + &[ + UsePathSegment::Self_, + UsePathSegment::Super, + ident("nested"), + ], + ) + .expect("spec should resolve"); + assert_eq!(spec, "../nested.rss"); + let spec = use_path_to_spec( + &path, + 1, + &[UsePathSegment::Self_, UsePathSegment::Self_, ident("x")], + ) + .expect("spec should resolve"); + assert_eq!(spec, "./x.rss"); + } + + #[test] + fn use_path_to_spec_rejects_leading_crate() { + let path = PathBuf::from("/root/main.rss"); + let err = use_path_to_spec(&path, 4, &[ident("crate"), ident("x")]) + .expect_err("crate:: should be rejected"); + let message = err.to_string(); + assert!( + message.contains("crate:: paths are not supported"), + "unexpected error: {message}" + ); + assert!( + message.contains("line 4"), + "line should be preserved: {message}" + ); + } + + #[test] + fn use_path_to_spec_requires_module_name_after_qualifiers() { + let path = PathBuf::from("/root/main.rss"); + let err = use_path_to_spec(&path, 2, &[UsePathSegment::Self_]) + .expect_err("bare self:: should be rejected"); + assert!( + err.to_string() + .contains("expected module name after path qualifiers"), + "unexpected error: {err}" + ); + } + + #[test] + fn use_path_to_spec_mid_path_qualifier_words_are_literal_segments() { + let path = PathBuf::from("/root/main.rss"); + let spec = use_path_to_spec(&path, 1, &[ident("a"), UsePathSegment::Self_, ident("b")]) + .expect("mid-path self should be a literal segment"); + assert_eq!(spec, "a/self/b.rss"); + } + + #[test] + fn module_graph_ids_are_deterministic_and_deduplicated() { + let mut graph = ModuleGraph::new(); + let root = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + assert_eq!(root, ModuleId(0)); + let again = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + assert_eq!( + again, root, + "re-registering the same identity reuses the node" + ); + + let nested = graph.add_node( + PathBuf::from("/root/nested.rss"), + "/root/nested.rss".to_string(), + Vec::new(), + ); + assert_eq!(nested, ModuleId(1)); + assert_eq!(graph.node(nested).expect("node exists").source, SourceId(1)); + } + + #[test] + fn module_graph_same_stem_modules_are_distinct() { + let mut graph = ModuleGraph::new(); + let first = graph.add_node( + PathBuf::from("/root/a/common.rss"), + "/root/a/common.rss".to_string(), + Vec::new(), + ); + let second = graph.add_node( + PathBuf::from("/root/b/common.rss"), + "/root/b/common.rss".to_string(), + Vec::new(), + ); + assert_ne!( + first, second, + "modules that share a stem but differ by directory must be distinct" + ); + assert_eq!(graph.len(), 2); + assert_eq!( + graph.module_id_for_identity(PathBuf::from("/root/b/common.rss").as_path()), + Some(second) + ); + } + + #[test] + fn module_graph_records_import_edges_and_targets() { + let mut graph = ModuleGraph::new(); + let root = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + let nested = graph.add_node( + PathBuf::from("/root/nested.rss"), + "/root/nested.rss".to_string(), + Vec::new(), + ); + graph.add_import( + root, + ResolvedImport { + kind: ImportTargetKind::FileModule, + spec: "./nested.rss".to_string(), + clause: ImportClause::Namespace("nested".to_string()), + span: Span::new(0, 0, 0), + line: 1, + target: Some(nested), + }, + ); + graph.add_import( + root, + ResolvedImport { + kind: ImportTargetKind::BuiltinNamespace, + spec: "json.rss".to_string(), + clause: ImportClause::AllPublic, + span: Span::new(0, 0, 0), + line: 2, + target: None, + }, + ); + let node = graph.node(root).expect("root node exists"); + assert_eq!(node.imports.len(), 2); + assert_eq!(node.imports[0].target, Some(nested)); + assert_eq!(node.imports[1].kind, ImportTargetKind::BuiltinNamespace); + assert_eq!(node.imports[1].target, None); + } + + #[test] + fn symbol_ids_are_module_scoped() { + let a = SymbolId { + module: ModuleId(0), + index: 3, + }; + let b = SymbolId { + module: ModuleId(1), + index: 3, + }; + assert_ne!(a, b, "same index in different modules must differ"); + assert_eq!( + SymbolId { + module: ModuleId(0), + index: 3 + }, + a + ); + } + + #[test] + fn declaration_symbols_are_deterministic_and_module_owned() { + let mut graph = ModuleGraph::new(); + let first = graph.add_node( + PathBuf::from("/root/a.rss"), + "/root/a.rss".to_string(), + Vec::new(), + ); + let second = graph.add_node( + PathBuf::from("/root/b.rss"), + "/root/b.rss".to_string(), + Vec::new(), + ); + let first_symbol = graph + .add_declaration(first, "run", true) + .expect("declaration registers"); + let second_symbol = graph + .add_declaration(second, "run", true) + .expect("declaration registers"); + assert_eq!( + first_symbol, + SymbolId { + module: first, + index: 0 + }, + "first declaration of a module owns symbol index 0" + ); + assert_eq!( + second_symbol, + SymbolId { + module: second, + index: 0 + }, + "same index in a different module is a different symbol" + ); + assert_ne!(first_symbol, second_symbol); + + let next = graph + .add_declaration(first, "helper", false) + .expect("declaration registers"); + assert_eq!( + next, + SymbolId { + module: first, + index: 1 + }, + "symbols are assigned in declaration order" + ); + assert_eq!(graph.declaration_symbol(first, "run"), Some(first_symbol)); + assert_eq!(graph.declaration_symbol(second, "run"), Some(second_symbol)); + } + + #[test] + fn export_table_contains_only_public_declarations() { + let mut graph = ModuleGraph::new(); + let module = graph.add_node( + PathBuf::from("/root/lib.rss"), + "/root/lib.rss".to_string(), + Vec::new(), + ); + let public = graph + .add_declaration(module, "visible", true) + .expect("public declaration registers"); + graph + .add_declaration(module, "hidden", false) + .expect("private declaration registers"); + + let node = graph.node(module).expect("node exists"); + assert_eq!( + node.exports.len(), + 1, + "only the pub declaration is exported" + ); + assert_eq!(node.exports[0].name, "visible"); + assert_eq!(node.exports[0].symbol, public); + assert_eq!(graph.symbol_for_export(module, "visible"), Some(public)); + assert_eq!( + graph.symbol_for_export(module, "hidden"), + None, + "private declarations never enter the export table" + ); + assert!( + !graph + .declaration(module, "hidden") + .expect("declaration exists") + .public + ); + assert_eq!(node.declarations.len(), 2); + } + + #[test] + fn duplicate_local_declaration_is_rejected() { + let mut graph = ModuleGraph::new(); + let module = graph.add_node( + PathBuf::from("/root/lib.rss"), + "/root/lib.rss".to_string(), + Vec::new(), + ); + graph + .add_declaration(module, "run", true) + .expect("first declaration registers"); + let err = graph + .add_declaration(module, "run", false) + .expect_err("second same-named declaration must be rejected"); + assert!( + err.contains("duplicate local declaration 'run'"), + "unexpected error: {err}" + ); + // The same name in another module is not a duplicate. + let other = graph.add_node( + PathBuf::from("/root/other.rss"), + "/root/other.rss".to_string(), + Vec::new(), + ); + graph + .add_declaration(other, "run", true) + .expect("independent module may reuse the name"); + } + + #[test] + fn imported_bindings_stay_separate_from_local_declarations() { + let mut graph = ModuleGraph::new(); + let importer = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + let source = graph.add_node( + PathBuf::from("/root/util.rss"), + "/root/util.rss".to_string(), + Vec::new(), + ); + let exported = graph + .add_declaration(source, "helper", true) + .expect("source export registers"); + graph + .add_imported_binding( + importer, + ImportedBinding { + local_name: "helper".to_string(), + source_module: source, + source_symbol: exported, + source_name: "helper".to_string(), + }, + ) + .expect("imported binding registers"); + + let node = graph.node(importer).expect("importer node exists"); + assert_eq!( + node.declarations.len(), + 0, + "imported symbols are not local declarations" + ); + assert_eq!( + node.exports.len(), + 0, + "imported symbols never enter the export table" + ); + assert_eq!(node.imported_bindings.len(), 1); + let binding = graph + .imported_binding(importer, "helper") + .expect("binding exists"); + assert_eq!(binding.source_module, source); + assert_eq!(binding.source_symbol, exported); + assert_eq!(binding.source_name, "helper"); + } + + #[test] + fn same_named_declarations_across_modules_coexist() { + let mut graph = ModuleGraph::new(); + let first = graph.add_node( + PathBuf::from("/root/a/util.rss"), + "/root/a/util.rss".to_string(), + Vec::new(), + ); + let second = graph.add_node( + PathBuf::from("/root/b/util.rss"), + "/root/b/util.rss".to_string(), + Vec::new(), + ); + let first_helper = graph + .add_declaration(first, "helper", false) + .expect("private helper registers"); + let second_helper = graph + .add_declaration(second, "helper", false) + .expect("private helper registers"); + assert_ne!( + first_helper, second_helper, + "same-named helpers in independent modules have distinct symbols" + ); + assert_eq!( + graph.declaration_symbol(first, "helper"), + Some(first_helper) + ); + assert_eq!( + graph.declaration_symbol(second, "helper"), + Some(second_helper) + ); + + // Same-named *public* functions in independent modules coexist too, + // each in its own export table. + graph + .add_declaration(first, "run", true) + .expect("public run registers in first module"); + graph + .add_declaration(second, "run", true) + .expect("public run registers in second module"); + let first_run = graph + .symbol_for_export(first, "run") + .expect("first export exists"); + let second_run = graph + .symbol_for_export(second, "run") + .expect("second export exists"); + assert_ne!(first_run, second_run); + } + + #[test] + fn no_implicit_transitive_reexport_through_the_graph() { + let mut graph = ModuleGraph::new(); + let root = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + let middle = graph.add_node( + PathBuf::from("/root/middle.rss"), + "/root/middle.rss".to_string(), + Vec::new(), + ); + let leaf = graph.add_node( + PathBuf::from("/root/leaf.rss"), + "/root/leaf.rss".to_string(), + Vec::new(), + ); + let shared = graph + .add_declaration(leaf, "shared", true) + .expect("leaf export registers"); + let middle_own = graph + .add_declaration(middle, "middle_own", true) + .expect("middle export registers"); + + // middle imports leaf's export; root imports middle's export. + for (importer, source, source_symbol, source_name) in [ + (middle, leaf, shared, "shared"), + (root, middle, middle_own, "middle_own"), + ] { + graph + .add_imported_binding( + importer, + ImportedBinding { + local_name: source_name.to_string(), + source_module: source, + source_symbol, + source_name: source_name.to_string(), + }, + ) + .expect("imported binding registers"); + } + + assert_eq!( + graph.symbol_for_export(middle, "shared"), + None, + "middle's export table must not re-export leaf's function" + ); + assert_eq!( + graph.symbol_for_export(root, "shared"), + None, + "root's export table must not see leaf's function through middle" + ); + assert_eq!( + graph.symbol_for_export(middle, "middle_own"), + Some(middle_own), + "middle's own public declaration stays in its export table" + ); + assert_eq!( + graph.symbol_for_export(root, "middle_own"), + None, + "root's export table is empty: direct imports become bindings, not exports" + ); + assert!(graph.imported_binding(root, "middle_own").is_some()); + assert!(graph.imported_binding(middle, "shared").is_some()); + assert!( + graph + .node(root) + .expect("root node exists") + .exports + .is_empty() + ); + } + + #[test] + fn imported_binding_clashing_with_local_declaration_is_rejected() { + let mut graph = ModuleGraph::new(); + let importer = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + let source = graph.add_node( + PathBuf::from("/root/util.rss"), + "/root/util.rss".to_string(), + Vec::new(), + ); + let exported = graph + .add_declaration(source, "helper", true) + .expect("source export registers"); + graph + .add_declaration(importer, "helper", true) + .expect("local declaration registers"); + + let err = graph + .add_imported_binding( + importer, + ImportedBinding { + local_name: "helper".to_string(), + source_module: source, + source_symbol: exported, + source_name: "helper".to_string(), + }, + ) + .expect_err("binding a name already declared locally must be rejected"); + assert!( + err.contains("conflicts with a local declaration"), + "unexpected error: {err}" + ); + } + + #[test] + fn duplicate_imported_bindings_are_recorded_like_the_legacy_merge() { + // Two modules exporting the same name, both imported into one module: + // the legacy pipeline merges such imports by name, so the graph keeps + // every binding instead of rejecting the second one. + let mut graph = ModuleGraph::new(); + let importer = graph.add_node( + PathBuf::from("/root/main.rss"), + "/root/main.rss".to_string(), + Vec::new(), + ); + let first = graph.add_node( + PathBuf::from("/root/a/util.rss"), + "/root/a/util.rss".to_string(), + Vec::new(), + ); + let second = graph.add_node( + PathBuf::from("/root/b/util.rss"), + "/root/b/util.rss".to_string(), + Vec::new(), + ); + let first_helper = graph + .add_declaration(first, "helper", true) + .expect("first export registers"); + let second_helper = graph + .add_declaration(second, "helper", true) + .expect("second export registers"); + graph + .add_imported_binding( + importer, + ImportedBinding { + local_name: "helper".to_string(), + source_module: first, + source_symbol: first_helper, + source_name: "helper".to_string(), + }, + ) + .expect("first binding registers"); + graph + .add_imported_binding( + importer, + ImportedBinding { + local_name: "helper".to_string(), + source_module: second, + source_symbol: second_helper, + source_name: "helper".to_string(), + }, + ) + .expect("second binding with the same local name registers"); + + let node = graph.node(importer).expect("importer node exists"); + assert_eq!(node.imported_bindings.len(), 2); + assert_eq!(node.imported_bindings[0].source_module, first); + assert_eq!(node.imported_bindings[1].source_module, second); + assert_eq!( + node.declarations.len(), + 0, + "bindings are never declarations" + ); + assert!(node.exports.is_empty(), "bindings are never exports"); + } +} diff --git a/src/compiler/parser/expressions.rs b/src/compiler/parser/expressions.rs index 451b1d57..e9c6217e 100644 --- a/src/compiler/parser/expressions.rs +++ b/src/compiler/parser/expressions.rs @@ -398,7 +398,7 @@ impl Parser { .get(1..) .map(|tail| tail.to_vec()) .unwrap_or_default(); - if let Some((builtin_namespace, builtin_member)) = + let expr = if let Some((builtin_namespace, builtin_member)) = self.resolve_builtins_call_path(&name, &member, &subpath) { let builtin_namespace = builtin_namespace.to_string(); @@ -406,23 +406,37 @@ impl Parser { if let Some(builtin) = resolve_builtin_namespace_call(&builtin_namespace, &builtin_member) { - let expr = - self.build_builtin_call_expr_with_type_args(builtin, args, type_args)?; - return Ok(expr); + self.build_builtin_call_expr_with_type_args(builtin, args, type_args)? + } else { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: format!( + "unknown builtin function '{}::{}'", + builtin_namespace, builtin_member + ), + }); } + } else if let Some(host_name) = + self.resolve_host_namespace_call_target(&name, &member, &subpath) + { + self.build_host_call_expr_with_type_args(&host_name, args, type_args)? + } else if self.allow_implicit_externs + && self.module_namespace_alias(&name).is_some() + { + // File-module namespace call (`alias::member(...)`): the + // parser cannot resolve the member against the target + // module's exports — only the source loader can. Emit an + // implicit extern carrying the qualified name; the loader + // resolves the call to a `ModuleCall` or rejects it. + // Type-argument validation is deferred to the loader, + // which knows the exported type parameters. + let qualified = format!("{}::{}", name, path_segments.join("::")); + let decl = self.resolve_function_for_call(&qualified, args.len())?; + Expr::Call(decl.index, type_args, args) + } else { return Err(ParseError { - span: None, - code: None, - line: self.current_line(), - message: format!( - "unknown builtin function '{}::{}'", - builtin_namespace, builtin_member - ), - }); - } - let host_name = self - .resolve_host_namespace_call_target(&name, &member, &subpath) - .ok_or_else(|| ParseError { span: None, code: None, line: self.current_line(), @@ -432,8 +446,11 @@ impl Parser { path_segments.join("::"), builtin_namespace_hint() ), - })?; - let expr = self.build_host_call_expr_with_type_args(&host_name, args, type_args)?; + }); + }; + // Namespace calls participate in postfix access like any + // other call (`iter::range(n)[0]`, `json::decode::(s).x`). + let expr = self.parse_postfix_access(expr)?; return Ok(expr); } @@ -488,7 +505,11 @@ impl Parser { } } else { let decl = self.resolve_function_for_call(&name, args.len())?; - self.validate_named_call_type_args(&decl, &type_args)?; + // Implicit externs mirror imported calls the loader + // validates against the exported signature. + if !self.is_implicit_extern(&name) { + self.validate_named_call_type_args(&decl, &type_args)?; + } Expr::Call(decl.index, type_args, args) } } else if let Some(expr) = self.try_build_language_builtin_call(&name, &args)? { @@ -507,7 +528,14 @@ impl Parser { self.build_host_call_expr_with_type_args(&host_name, args, type_args)? } else { let decl = self.resolve_function_for_call(&name, args.len())?; - self.validate_named_call_type_args(&decl, &type_args)?; + // Import-scan and module-mode parses tolerate type + // arguments on calls whose target only the source + // loader can type; the loader validates them against + // the exported signature. Plain compile parses + // validate locally declared functions normally. + if !self.import_scan_mode && !self.is_implicit_extern(&name) { + self.validate_named_call_type_args(&decl, &type_args)?; + } Expr::Call(decl.index, type_args, args) } } else { @@ -541,6 +569,11 @@ impl Parser { }); } Expr::FunctionRef(index, Vec::new()) + } else if self.allow_implicit_externs { + // Module mode: the name may be an imported function + // binding the loader resolves to a module symbol + // (`Expr::ModuleFunctionRef`) before unit merge. + Expr::UnresolvedFunctionRef { name, type_args } } else { return Err(ParseError { span: None, @@ -1610,7 +1643,12 @@ impl Parser { args: Vec, type_args: Vec, ) -> Result { - self.validate_host_call_type_args(host_name, &type_args)?; + // Import-scan parses discard their IR; the compile parse validates + // host type arguments unless the namespace may name a file module + // (deferred to the source loader, which knows the exports). + if !self.import_scan_mode && self.host_type_args_validated_at_parse(host_name) { + self.validate_host_call_type_args(host_name, &type_args)?; + } let arity = u8::try_from(args.len()).map_err(|_| ParseError { span: None, code: None, @@ -1621,6 +1659,20 @@ impl Parser { Ok(Expr::Call(decl.index, type_args, args)) } + /// Whether host type arguments are validated at parse time. + /// + /// Single-segment import forms (`use module;`, `use module::{wrap}`) may + /// name a file module; the parser cannot know, so calls through such + /// namespaces defer type-argument validation to the source loader, which + /// validates against the module's exported type parameters. Builtin + /// namespaces keep their parse-time validation. + fn host_type_args_validated_at_parse(&self, host_name: &str) -> bool { + match host_name.split_once("::") { + Some((namespace, _)) => is_builtin_namespace(namespace), + None => true, + } + } + pub(super) fn contextualize_function_value( &self, expr: &mut Expr, @@ -2392,7 +2444,7 @@ fn builtin_generic_type_arg_arity(builtin: BuiltinFunction) -> GenericCallableTy } } -fn host_generic_type_arg_arity(host_name: &str) -> Option { +pub(crate) fn host_generic_type_arg_arity(host_name: &str) -> Option { match host_name { "json::decode" => Some(1), _ => None, diff --git a/src/compiler/parser/mod.rs b/src/compiler/parser/mod.rs index f989111e..98f6b40d 100644 --- a/src/compiler/parser/mod.rs +++ b/src/compiler/parser/mod.rs @@ -15,8 +15,10 @@ use crate::builtins::{ BuiltinFunction, builtin_namespace_hint, default_host_callable, is_builtin_namespace, resolve_builtin_namespace_call, }; +use crate::compiler::modules::{UseDecl, UsePathSegment}; use crate::compiler::source_map::{SourceId, Span}; +pub(crate) use self::expressions::host_generic_type_arg_arity; use self::lexer::{Lexer, ParserFormatArg, Token, TokenKind, is_ident_continue, is_ident_start}; use self::symbols::is_virtual_host_namespace_spec; use super::{ @@ -138,6 +140,19 @@ pub(super) struct Parser { host_namespace_aliases: HashMap, direct_host_call_aliases: HashMap, direct_host_wildcard_imports: HashSet, + /// Names created through the implicit-extern fallback (module mode). + /// The source loader uses this marker to keep synthetic externs out of + /// module declaration/export tables and to resolve (or reject) their + /// call sites. + implicit_extern_names: HashSet, + /// Namespace aliases introduced by file-module `use` directives. + /// + /// Unlike [`Parser::host_namespace_aliases`] these are recorded in every + /// parse mode; a namespace call that is neither builtin nor host resolves + /// through this map into a loader-resolved module call placeholder. + module_namespace_aliases: HashMap, + use_declarations: Vec, + import_scan_mode: bool, mutable_locals: Vec, borrowed_map_iter_locals: Vec, local_schemas: HashMap, @@ -155,6 +170,7 @@ impl Parser { allow_implicit_externs: bool, allow_implicit_semicolons: bool, enforce_mutable_bindings: bool, + import_scan_mode: bool, dialect: &'static dyn ParserDialect, ) -> Result { let mut lexer = Lexer::new(source, source_id, dialect); @@ -193,6 +209,10 @@ impl Parser { host_namespace_aliases: HashMap::new(), direct_host_call_aliases: HashMap::new(), direct_host_wildcard_imports: HashSet::new(), + implicit_extern_names: HashSet::new(), + module_namespace_aliases: HashMap::new(), + use_declarations: Vec::new(), + import_scan_mode, mutable_locals: Vec::new(), borrowed_map_iter_locals: Vec::new(), local_schemas: HashMap::new(), @@ -214,6 +234,7 @@ impl Parser { allow_implicit_externs, allow_implicit_semicolons, enforce_mutable_bindings, + false, dialect, )?; for binding in predeclared_locals { @@ -222,6 +243,10 @@ impl Parser { Ok(parser) } + pub(super) fn use_declarations(&self) -> Vec { + self.use_declarations.clone() + } + pub(super) fn parse_program(&mut self) -> Result, ParseError> { self.predeclare_functions()?; let mut stmts = Vec::new(); @@ -336,6 +361,7 @@ impl Parser { type_params, exported, return_type: ValueType::Unknown, + symbol: None, }; self.functions.insert(name, decl.clone()); self.function_list.push(decl); @@ -388,6 +414,28 @@ impl Parser { self.unknown_type_spans.clone() } + pub(super) fn implicit_extern_names(&self) -> Vec { + let mut names = self + .implicit_extern_names + .iter() + .cloned() + .collect::>(); + names.sort(); + names + } + + pub(super) fn is_implicit_extern(&self, name: &str) -> bool { + self.implicit_extern_names.contains(name) + } + + /// Look up a file-module namespace alias recorded from a structured + /// `use` directive (both parse modes). + pub(super) fn module_namespace_alias(&self, namespace: &str) -> Option<&str> { + self.module_namespace_aliases + .get(namespace) + .map(String::as_str) + } + fn validate_schema_reference_sites(&self) -> Result<(), ParseError> { for (name, arg_count, line, span) in &self.schema_reference_sites { let Some(decl) = self.struct_schemas.get(name) else { diff --git a/src/compiler/parser/statements.rs b/src/compiler/parser/statements.rs index 53811c91..92e24cb3 100644 --- a/src/compiler/parser/statements.rs +++ b/src/compiler/parser/statements.rs @@ -1,5 +1,17 @@ use super::*; +use crate::compiler::source_loader::{ImportClause, NamedImport}; + +/// Classify a `use` path segment: leading `self`/`super` words become +/// qualifiers; every other segment is a plain identifier. +fn classify_use_segment(segment: &str) -> UsePathSegment { + match segment { + "self" => UsePathSegment::Self_, + "super" => UsePathSegment::Super, + _ => UsePathSegment::Ident(segment.to_string()), + } +} + impl Parser { pub(super) fn parse_stmt(&mut self) -> Result { if self.match_kind(&TokenKind::Pub) { @@ -94,21 +106,42 @@ impl Parser { pub(super) fn parse_use_stmt(&mut self) -> Result { let line = self.last_line(); - let namespace = self.expect_ident("expected namespace after 'use'")?; - if self.match_kind(&TokenKind::Semicolon) { - self.host_namespace_aliases - .insert(namespace.clone(), namespace); - return Ok(Stmt::Noop { line }); - } + let directive_start = self + .tokens + .get(self.pos.saturating_sub(1)) + .map(|token| token.span.lo) + .unwrap_or(0); - if self.match_kind(&TokenKind::As) { - let alias = self.expect_ident("expected namespace alias after 'as'")?; - self.expect(&TokenKind::Semicolon, "expected ';' after use alias")?; - self.host_namespace_aliases.insert(alias, namespace); - return Ok(Stmt::Noop { line }); - } + let namespace = self.expect_ident("expected namespace after 'use'")?; if !self.match_path_separator() { + // Single-segment host-namespace forms: `use io;`, `use io as x;`. + if self.match_kind(&TokenKind::Semicolon) { + self.host_namespace_aliases + .insert(namespace.clone(), namespace.clone()); + self.record_use_decl( + vec![classify_use_segment(&namespace)], + ImportClause::AllPublic, + line, + directive_start, + ); + return Ok(Stmt::Noop { line }); + } + + if self.match_kind(&TokenKind::As) { + let alias = self.expect_ident("expected namespace alias after 'as'")?; + self.expect(&TokenKind::Semicolon, "expected ';' after use alias")?; + self.host_namespace_aliases + .insert(alias.clone(), namespace.clone()); + self.record_use_decl( + vec![classify_use_segment(&namespace)], + ImportClause::Namespace(alias), + line, + directive_start, + ); + return Ok(Stmt::Noop { line }); + } + return Err(ParseError { span: None, code: None, @@ -130,45 +163,210 @@ impl Parser { }); } + if namespace == "crate" { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: "crate:: paths are not supported; use relative module paths".to_string(), + }); + } + if self.match_kind(&TokenKind::Star) { - self.direct_host_wildcard_imports.insert(namespace); + self.direct_host_wildcard_imports.insert(namespace.clone()); self.expect( &TokenKind::Semicolon, "expected ';' after host wildcard import", )?; + self.record_use_decl( + vec![classify_use_segment(&namespace)], + ImportClause::AllPublic, + line, + directive_start, + ); return Ok(Stmt::Noop { line }); } - self.expect(&TokenKind::LBrace, "expected '{' after host import path")?; - if self.match_kind(&TokenKind::Star) { - self.direct_host_wildcard_imports.insert(namespace); - self.expect(&TokenKind::RBrace, "expected '}' after '*'")?; + if self.match_kind(&TokenKind::LBrace) { + let named = self.parse_use_named_list(&namespace, true)?; + self.expect(&TokenKind::RBrace, "expected '}' after use list")?; self.expect(&TokenKind::Semicolon, "expected ';' after use list")?; + self.record_use_decl( + vec![classify_use_segment(&namespace)], + ImportClause::Named(named), + line, + directive_start, + ); return Ok(Stmt::Noop { line }); } + // Multi-segment file-module path: `use a::b;`, `use a::b::*;`, + // `use a::b::{x};`, `use a::b as alias;`, with optional leading + // `self`/`super` qualifiers. These directives are consumed as + // structured nodes; the source loader resolves them against the + // module graph, so no host aliases are recorded here. + let mut path = vec![classify_use_segment(&namespace)]; + let mut qualifier_run = matches!(path[0], UsePathSegment::Self_ | UsePathSegment::Super); loop { - let imported = self.expect_ident("expected host function name in use list")?; - let local = if self.match_kind(&TokenKind::As) { - self.expect_ident("expected local alias after 'as'")? - } else { - imported.clone() - }; - let target = format!("{namespace}::{imported}"); - if let Some(existing) = self.direct_host_call_aliases.get(&local) - && existing != &target - { + if self.match_kind(&TokenKind::Star) { + self.expect(&TokenKind::Semicolon, "expected ';' after use wildcard")?; + self.record_use_decl(path, ImportClause::AllPublic, line, directive_start); + return Ok(Stmt::Noop { line }); + } + if self.match_kind(&TokenKind::LBrace) { + let named = self.parse_use_named_list(&namespace, false)?; + self.expect(&TokenKind::RBrace, "expected '}' after use list")?; + self.expect(&TokenKind::Semicolon, "expected ';' after use list")?; + self.record_use_decl(path, ImportClause::Named(named), line, directive_start); + return Ok(Stmt::Noop { line }); + } + let segment = self.expect_ident("expected module path segment after '::'")?; + if qualifier_run && segment == "crate" { return Err(ParseError { span: None, code: None, line: self.current_line(), - message: format!( - "host import alias '{local}' already maps to '{existing}', cannot remap to '{target}'" - ), + message: "crate:: paths are not supported; use relative module paths" + .to_string(), }); } - self.direct_host_call_aliases.insert(local, target); + let classified = if qualifier_run { + classify_use_segment(&segment) + } else { + UsePathSegment::Ident(segment) + }; + qualifier_run = matches!(classified, UsePathSegment::Self_ | UsePathSegment::Super); + path.push(classified); + if !self.match_path_separator() { + break; + } + } + + if self.match_kind(&TokenKind::As) { + let alias = self.expect_ident("expected namespace alias after 'as'")?; + self.expect(&TokenKind::Semicolon, "expected ';' after use alias")?; + self.record_use_decl(path, ImportClause::Namespace(alias), line, directive_start); + } else { + self.expect(&TokenKind::Semicolon, "expected ';' after use directive")?; + self.record_use_decl(path, ImportClause::AllPublic, line, directive_start); + } + Ok(Stmt::Noop { line }) + } + + /// Record a structured `use` declaration with its directive span. + /// + /// In import-scan mode (source-loader discovery) the parser also records + /// host aliases for file-module paths so that namespace calls parse + /// before the loader's resolution pass runs; the compile parse runs with + /// scan mode off and resolves module namespaces through the structured + /// declarations instead. The alias is the clause alias for namespace + /// imports and the default namespace (last path segment) for all-public + /// and named imports. + fn record_use_decl( + &mut self, + path: Vec, + clause: ImportClause, + line: u32, + directive_start: usize, + ) { + let source_id = self.current_span().source_id; + let end = self + .tokens + .get(self.pos.saturating_sub(1)) + .map(|token| token.span.hi) + .unwrap_or(directive_start); + if self.import_scan_mode { + let alias = match &clause { + ImportClause::Namespace(alias) => Some(alias.clone()), + ImportClause::AllPublic | ImportClause::Named(_) => match path.last() { + Some(UsePathSegment::Ident(name)) => Some(name.clone()), + _ => None, + }, + ImportClause::Prefix(_) => None, + }; + if let Some(alias) = alias { + let joined = path + .iter() + .map(|segment| match segment { + UsePathSegment::Self_ => "self".to_string(), + UsePathSegment::Super => "super".to_string(), + UsePathSegment::Ident(name) => name.clone(), + }) + .collect::>() + .join("::"); + self.host_namespace_aliases.insert(alias, joined); + } + } + // File-module namespace aliases are recorded in every parse mode so + // the compile parse can recognize `alias::member(...)` calls and emit + // a loader-resolved placeholder. The alias mirrors the loader's + // clause-based namespace table: the `as` alias for namespace + // imports, the last path segment for all-public imports, and no + // namespace for named imports (which bind direct names only). + let module_alias = match &clause { + ImportClause::Namespace(alias) => Some(alias.clone()), + ImportClause::AllPublic => match path.last() { + Some(UsePathSegment::Ident(name)) => Some(name.clone()), + _ => None, + }, + ImportClause::Named(_) | ImportClause::Prefix(_) => None, + }; + if let Some(alias) = module_alias { + let joined = path + .iter() + .map(|segment| match segment { + UsePathSegment::Self_ => "self".to_string(), + UsePathSegment::Super => "super".to_string(), + UsePathSegment::Ident(name) => name.clone(), + }) + .collect::>() + .join("::"); + self.module_namespace_aliases.insert(alias, joined); + } + self.use_declarations.push(UseDecl { + path, + clause, + span: Span::new(source_id, directive_start, end), + line: line as usize, + }); + } + /// Parse the named list of a `use` directive. + /// + /// Single-segment forms keep the legacy host behavior: every binding is + /// recorded as a direct host call alias (`use io::{read};` maps `read` to + /// `io::read`). Multi-segment file-module forms only collect the bindings; + /// the module graph's resolution pass owns their resolution. + fn parse_use_named_list( + &mut self, + namespace: &str, + record_host_aliases: bool, + ) -> Result, ParseError> { + let mut named = Vec::::new(); + loop { + let imported = self.expect_ident("expected host function name in use list")?; + let local = if self.match_kind(&TokenKind::As) { + self.expect_ident("expected local alias after 'as'")? + } else { + imported.clone() + }; + if record_host_aliases { + let target = format!("{namespace}::{imported}"); + if let Some(existing) = self.direct_host_call_aliases.get(&local) + && existing != &target + { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: format!( + "host import alias '{local}' already maps to '{existing}', cannot remap to '{target}'" + ), + }); + } + self.direct_host_call_aliases.insert(local.clone(), target); + } + named.push(NamedImport { imported, local }); if self.match_kind(&TokenKind::Comma) { if self.check(&TokenKind::RBrace) { break; @@ -177,9 +375,7 @@ impl Parser { } break; } - self.expect(&TokenKind::RBrace, "expected '}' after use list")?; - self.expect(&TokenKind::Semicolon, "expected ';' after use list")?; - Ok(Stmt::Noop { line }) + Ok(named) } pub(super) fn parse_js_import_stmt(&mut self) -> Result { @@ -494,6 +690,7 @@ impl Parser { type_params: type_params.clone(), exported, return_type, + symbol: None, }; self.functions.insert(name.clone(), decl.clone()); let current_line = self.current_line(); diff --git a/src/compiler/parser/symbols.rs b/src/compiler/parser/symbols.rs index 35adcd84..8290e75f 100644 --- a/src/compiler/parser/symbols.rs +++ b/src/compiler/parser/symbols.rs @@ -280,6 +280,7 @@ impl Parser { type_params: Vec::new(), exported: true, return_type: ValueType::Unknown, + symbol: None, }; self.functions.insert(name.to_string(), decl.clone()); self.function_list.push(decl.clone()); @@ -310,6 +311,10 @@ impl Parser { message: format!("name '{name}' already used by a local binding"), }); } + // The module loader resolves (or rejects) every implicit extern's + // call sites; the marker keeps synthetic externs out of module + // declaration/export tables. + self.implicit_extern_names.insert(name.to_string()); let index = self.next_function; self.next_function = self.next_function.checked_add(1).ok_or(ParseError { span: None, @@ -328,6 +333,7 @@ impl Parser { type_params: Vec::new(), exported: true, return_type: ValueType::Unknown, + symbol: None, }; self.functions.insert(name.to_string(), decl.clone()); self.function_list.push(decl.clone()); @@ -376,6 +382,7 @@ impl Parser { type_params: Vec::new(), exported: false, return_type: known_host_return_type(name), + symbol: None, }; self.functions.insert(name.to_string(), decl.clone()); self.function_list.push(decl.clone()); diff --git a/src/compiler/pipeline.rs b/src/compiler/pipeline.rs index 7f98363d..c3d63a23 100644 --- a/src/compiler/pipeline.rs +++ b/src/compiler/pipeline.rs @@ -7,13 +7,14 @@ use super::ReplLocalState; use super::codegen::Compiler; use super::frontends; use super::ir::{Expr, FrontendIr, FunctionDecl, FunctionImpl, LocalSlot, Stmt, TypeSchema}; -use super::linker::merge_units; +use super::linker::{ParsedUnit, merge_units}; +use super::modules::ModuleGraph; use super::source_loader::load_units_for_source_file; use super::source_map::SourceMap; use super::{ CompileError, CompileSourceFileOptions, CompiledProgram, CompiledReplProgram, ParseError, - ReplLocalBinding, SourceError, SourceFlavor, SourcePathError, TypingMode, lifetime, parser, - typing, + ReplLocalBinding, SourceError, SourceFlavor, SourcePathError, TypingMode, lifetime, + materialization, parser, typing, }; #[derive(Clone, Copy, Debug, Default)] @@ -173,7 +174,9 @@ fn record_expr_local_debug_ranges( | Expr::Bool(_) | Expr::Bytes(_) | Expr::String(_) - | Expr::FunctionRef(..) => {} + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => {} Expr::Var(index) | Expr::MoveVar(index) => { note_local_use(ranges, *index, line); } @@ -200,7 +203,7 @@ fn record_expr_local_debug_ranges( record_expr_local_debug_ranges(value, line, ranges); record_expr_local_debug_ranges(fallback, line, ranges); } - Expr::Call(_, _, args) => { + Expr::Call(_, _, args) | Expr::ModuleCall(_, _, args) => { for arg in args { record_expr_local_debug_ranges(arg, line, ranges); } @@ -386,6 +389,11 @@ fn compile_parsed_output_with_entry_locals( enable_local_move_semantics, ) .map_err(SourceError::Parse)?; + // Classify named callable materialization on the final merged IR + // (post-lifetime, so capture metadata and rewritten uses are + // authoritative). Codegen consumes `requires_callable_slot` to omit + // hidden callable slots for direct-only functions. + let callable_use_facts = materialization::classify_named_callables(&parsed); let type_info = typing::infer_types(&parsed, typing_mode, entry_local_types); let FrontendIr { stmts, @@ -402,6 +410,27 @@ fn compile_parsed_output_with_entry_locals( .map(|decl| (decl.index, decl)) .collect::>(); + // Milestone-5 observation for the crate's unit tests: capture the + // classification keyed by the merged flat function identity before the + // facts move into the Compiler, so tests observe exactly what the + // compiler received. Compiled into unit-test builds only; never part of + // the public API. + #[cfg(test)] + let mut callable_use_observations = functions + .iter() + .filter_map(|decl| { + callable_use_facts.get(&decl.index).map(|facts| { + materialization::CallableUseObservation { + function_index: decl.index, + name: decl.name.clone(), + facts: *facts, + } + }) + }) + .collect::>(); + #[cfg(test)] + callable_use_observations.sort_unstable_by_key(|observation| observation.function_index); + let mut runtime_import_functions: Vec = functions .iter() .filter(|func| !function_impls.contains_key(&func.index)) @@ -439,6 +468,7 @@ fn compile_parsed_output_with_entry_locals( compiler.set_root_local_count(locals); compiler.set_function_decls(function_decls); compiler.set_function_impls(function_impls); + compiler.set_callable_use_facts(callable_use_facts); compiler.set_struct_schemas(struct_schemas); compiler.set_host_import_return_types(host_import_return_types); compiler.set_host_import_signatures(host_import_signatures); @@ -470,6 +500,8 @@ fn compile_parsed_output_with_entry_locals( program, locals: runtime_locals, functions: visible_runtime_import_functions, + #[cfg(test)] + callable_use_facts: callable_use_observations, }) } @@ -579,7 +611,9 @@ fn schema_is_fully_known(schema: &TypeSchema) -> bool { | TypeSchema::GenericParam(_) => true, TypeSchema::Optional(inner) => schema_is_fully_known(inner), TypeSchema::Named(_, type_args) => type_args.iter().all(schema_is_fully_known), - TypeSchema::Array(item) | TypeSchema::Map(item) => schema_is_fully_known(item), + TypeSchema::Array(item) | TypeSchema::Map(item) => { + matches!(item.as_ref(), TypeSchema::Unknown) || schema_is_fully_known(item) + } TypeSchema::ArrayTuple(items) => items.iter().all(schema_is_fully_known), TypeSchema::ArrayTupleRest { prefix, rest } => { prefix.iter().all(schema_is_fully_known) && schema_is_fully_known(rest) @@ -867,8 +901,9 @@ fn lint_unknown_inferred_local_types_at_path_with_options_impl( ) -> Result, SourcePathError> { let mut source_map = SourceMap::new(); let source_id = source_map.add_source(path.display().to_string(), source.to_string()); - let (_root_parse_source, units) = load_units_for_source_file(path, flavor, source, options)?; - let parsed = units + let loaded = load_units_for_source_file(path, flavor, source, options)?; + let parsed = loaded + .units .into_iter() .last() .map(|unit| unit.parsed) @@ -886,8 +921,9 @@ fn collect_inferred_local_type_hints_at_path_with_options_impl( flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result, SourcePathError> { - let (_root_parse_source, units) = load_units_for_source_file(path, flavor, source, options)?; - let parsed = units + let loaded = load_units_for_source_file(path, flavor, source, options)?; + let parsed = loaded + .units .into_iter() .last() .map(|unit| unit.parsed) @@ -1319,6 +1355,45 @@ fn compile_source_with_flavor_impl( } } +fn compile_loaded_units( + source: String, + units: Vec, + flavor: SourceFlavor, + // Carried from the loader for Milestone 2+ (structured imports, symbol + // resolution); codegen output is unchanged until then. + _module_graph: ModuleGraph, + // Compilation-wide source map keyed by the module graph's `SourceId` + // space (milestone 5). Every span produced during load/merge references + // this map, so errors are returned with it and render from the owning + // source. + sources: SourceMap, +) -> Result { + let diagnostic_path = units + .iter() + .find(|unit| !unit.parsed.unknown_type_spans.is_empty()) + .map(|unit| PathBuf::from(&unit.source_name)); + let merged = merge_units(units)?; + compile_parsed_output( + source, + merged, + CompileBehavior::DEFAULT, + TypingMode::for_flavor(flavor), + matches!(flavor, SourceFlavor::RustScript), + ) + .map_err(|error| match (error, diagnostic_path) { + (SourceError::Parse(mut parse), Some(path)) + if parse.code.as_deref() == Some("E_STRICT_UNKNOWN_TYPE") => + { + parse.message = format!("{}: {}", path.display(), parse.message); + SourcePathError::SourceWithMap { + error: SourceError::Parse(parse), + sources, + } + } + (error, _) => SourcePathError::SourceWithMap { error, sources }, + }) +} + fn compile_source_with_flavor_and_options_impl( source: &str, flavor: SourceFlavor, @@ -1330,16 +1405,14 @@ fn compile_source_with_flavor_and_options_impl( } let path = virtual_inmemory_entry_path(flavor); - let (_root_parse_source, units) = load_units_for_source_file(&path, flavor, source, options)?; - let merged = merge_units(units)?; - compile_parsed_output( + let loaded = load_units_for_source_file(&path, flavor, source, options)?; + compile_loaded_units( source.to_string(), - merged, - CompileBehavior::DEFAULT, - TypingMode::for_flavor(flavor), - matches!(flavor, SourceFlavor::RustScript), + loaded.units, + flavor, + loaded.module_graph, + loaded.sources, ) - .map_err(SourcePathError::Source) } fn compile_source_at_path_with_flavor_and_options_impl( @@ -1348,16 +1421,14 @@ fn compile_source_at_path_with_flavor_and_options_impl( flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result { - let (_root_parse_source, units) = load_units_for_source_file(path, flavor, source, options)?; - let merged = merge_units(units)?; - compile_parsed_output( + let loaded = load_units_for_source_file(path, flavor, source, options)?; + compile_loaded_units( source.to_string(), - merged, - CompileBehavior::DEFAULT, - TypingMode::for_flavor(flavor), - matches!(flavor, SourceFlavor::RustScript), + loaded.units, + flavor, + loaded.module_graph, + loaded.sources, ) - .map_err(SourcePathError::Source) } fn virtual_inmemory_entry_path(flavor: SourceFlavor) -> PathBuf { @@ -1387,17 +1458,14 @@ fn compile_source_file_impl( ) -> Result { let flavor = SourceFlavor::from_path_with_options(path, options)?; let source_raw = std::fs::read_to_string(path)?; - let (_root_parse_source, units) = - load_units_for_source_file(path, flavor, &source_raw, options)?; - let merged = merge_units(units)?; - compile_parsed_output( + let loaded = load_units_for_source_file(path, flavor, &source_raw, options)?; + compile_loaded_units( source_raw, - merged, - CompileBehavior::DEFAULT, - TypingMode::for_flavor(flavor), - matches!(flavor, SourceFlavor::RustScript), + loaded.units, + flavor, + loaded.module_graph, + loaded.sources, ) - .map_err(SourcePathError::Source) } fn run_with_compiler_stack(f: F) -> T @@ -1424,3 +1492,269 @@ where } } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use crate::vm::Vm; + + use super::*; + + #[test] + fn production_path_callable_use_facts_observed() { + // Observe the milestone-5 classification through the real production + // pipeline (parse -> module merge -> lifetime -> classification -> + // Compiler) via the crate-internal test observation on + // CompiledProgram. Facts must be keyed by resolved flat identity + // and include the flow-aware dynamic-target and runtime-self facts; + // allocation behavior stays untouched (every named function keeps + // its prototype and hidden callable slot). + let source = r#" + fn direct_helper(x: int) -> int { x + 1 } + pub fn exported_helper(x: int) -> int { x + 2 } + fn stored_helper(x: int) -> int { x + 3 } + fn flow_helper() -> int { 4 } + fn consume(f) -> int { 1 } + fn apply(f) -> int { f(1) } + fn direct_recursive(n: int) -> int { + if n <= 0 => { 0 } else => { direct_recursive(n - 1) } + } + let captured = 42; + fn read_captured() -> int { captured } + fn captured_walk(n: int) -> int { + if n <= 0 => { captured } else => { captured_walk(n - 1) } + } + let stored = stored_helper; + let a = flow_helper; + let b = a; + b(); + consume(stored_helper); + apply(consume); + direct_helper(1); + exported_helper(1); + direct_recursive(3); + read_captured; + captured_walk(2); + "#; + let compiled = compile_source(source).expect("classification program should compile"); + let observations = &compiled.callable_use_facts; + let find = |name: &str| { + observations + .iter() + .find(|observation| observation.name == name) + .unwrap_or_else(|| panic!("observation for '{name}' missing: {observations:#?}")) + .facts + }; + assert_eq!( + observations.len(), + 9, + "every named script function must carry production-path facts" + ); + assert_eq!( + observations + .iter() + .map(|observation| observation.function_index) + .collect::>() + .len(), + 9, + "facts must be keyed by distinct resolved flat identities" + ); + + let direct = find("direct_helper"); + assert!(direct.called_directly); + assert!(!direct.referenced_as_value); + assert!(!direct.exported); + assert!(!direct.captures_environment); + assert!(!direct.dynamic_target_required); + assert!(!direct.runtime_self_required); + assert!(!direct.requires_callable_slot()); + + let exported = find("exported_helper"); + assert!(exported.called_directly); + assert!(exported.exported); + assert!(exported.requires_callable_slot()); + + let stored = find("stored_helper"); + assert!(stored.referenced_as_value); + assert!( + !stored.dynamic_target_required, + "passing a function value to a callee that never invokes it must not \ + mark a dynamic target (tracked flow only)" + ); + assert!(stored.requires_callable_slot()); + + let flow = find("flow_helper"); + assert!(flow.referenced_as_value); + assert!( + flow.dynamic_target_required, + "the alias chain `let a = flow_helper; let b = a; b();` must propagate \ + to the dynamic invocation" + ); + + let consume = find("consume"); + assert!(consume.called_directly); + assert!( + consume.dynamic_target_required, + "consume is passed to `apply`, whose parameter is dynamically invoked" + ); + + let recursive = find("direct_recursive"); + assert!(recursive.called_directly); + assert!(!recursive.captures_environment); + assert!( + !recursive.runtime_self_required, + "non-capturing direct recursion needs no runtime self identity" + ); + assert!(!recursive.requires_callable_slot()); + + let read_captured = find("read_captured"); + assert!(read_captured.captures_environment); + assert!(!read_captured.runtime_self_required); + + let captured_walk = find("captured_walk"); + assert!(captured_walk.captures_environment); + assert!( + captured_walk.runtime_self_required, + "capturing direct recursion retains the runtime self identity" + ); + assert!(captured_walk.requires_callable_slot()); + + // Milestone 6 lowering: every named function keeps its prototype; + // direct-only functions (no value reference, export, capture, or + // dynamic target) keep no hidden callable slot, while the + // materialized functions retain their runtime self slot. + assert_eq!(compiled.program.callable_prototypes.len(), 9); + let self_slots = compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_some()) + .count(); + assert_eq!( + self_slots, 6, + "exported, stored, flow, consume, and both capturing functions stay materialized" + ); + assert_eq!( + compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_none()) + .count(), + 3, + "direct_helper, apply, and direct_recursive are direct-only" + ); + assert_eq!(compiled.program.root_callable_bindings.len(), 4); + assert!( + compiled + .program + .code + .contains(&(crate::OpCode::CallScript as u8)), + "direct-only call sites emit CallScript" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, crate::vm::VmStatus::Halted); + } + + #[test] + fn production_path_module_merge_facts_follow_flat_indices() { + // Two modules each declare a private `helper` plus a `pub run` that + // calls it, merged through the real production pipeline. The + // classification must attribute facts to distinct resolved flat + // identities; assertions never parse the merged display names (a + // mangling policy change must not affect them) and instead check + // counts, index uniqueness, and the exported-vs-private semantic + // facts. + let options = CompileSourceFileOptions::new() + .with_module_override_source( + "a/util.rss", + "pub fn run() { helper(); }\nfn helper() { 11; }\n", + ) + .with_module_override_source( + "b/util.rss", + "pub fn run() { helper(); }\nfn helper() { 22; }\n", + ); + let source = "use a::util as au;\nuse b::util as bu;\nau::run();\nbu::run();\n"; + let compiled = + compile_source_with_flavor_and_options(source, SourceFlavor::RustScript, options) + .expect("same-named module helpers should compile"); + + let observations = &compiled.callable_use_facts; + assert_eq!( + observations.len(), + 4, + "both modules' run and both same-named helpers must carry facts: {observations:#?}" + ); + assert_eq!( + observations + .iter() + .map(|observation| observation.function_index) + .collect::>() + .len(), + 4, + "classification must be keyed by distinct resolved flat identities" + ); + + let runs = observations + .iter() + .filter(|observation| observation.facts.exported) + .collect::>(); + assert_eq!(runs.len(), 2, "both exported runs must survive the merge"); + for run in runs { + assert!(run.facts.called_directly); + assert!(run.facts.requires_callable_slot()); + } + + let helpers = observations + .iter() + .filter(|observation| !observation.facts.exported) + .collect::>(); + assert_eq!( + helpers.len(), + 2, + "both same-named private helpers must survive the merge" + ); + for helper in helpers { + assert!( + helper.facts.called_directly, + "each module's run calls its own same-named helper" + ); + assert!(!helper.facts.dynamic_target_required); + assert!(!helper.facts.requires_callable_slot()); + } + + // Milestone 6 allocation: every merged function keeps its prototype; + // the same-named private helpers are direct-only (no hidden slot), + // and both exported runs stay materialized and exported. + assert_eq!(compiled.program.callable_prototypes.len(), 4); + assert_eq!( + compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_some()) + .count(), + 2, + "both exported runs keep their runtime self slot" + ); + assert_eq!( + compiled + .program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_none()) + .count(), + 2, + "both same-named private helpers are direct-only" + ); + assert_eq!(compiled.program.root_callable_bindings.len(), 2); + assert_eq!(compiled.program.exported_callables.len(), 2); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, crate::vm::VmStatus::Halted); + } +} diff --git a/src/compiler/source_loader.rs b/src/compiler/source_loader.rs index 5478bae2..1228550b 100644 --- a/src/compiler/source_loader.rs +++ b/src/compiler/source_loader.rs @@ -1,7 +1,49 @@ +//! Semantic file-module loading. +//! +//! This module is the sole file-module path of the compiler (milestone 6): +//! there is no textual import rewriting, no synthetic imported-function +//! prelude, and no prelude line-map remapping anymore. Every module source is +//! parsed verbatim with the real frontend parser; `use` directives become +//! structured [`UseDecl`](crate::compiler::modules::UseDecl) nodes, the +//! [`ModuleGraph`] assigns deterministic [`ModuleId`]s/[`SourceId`]s and +//! records import edges, exports, and imported bindings, and calls to +//! imported functions are resolved to [`SymbolId`]s before unit merge. +//! +//! ## Load pipeline +//! +//! 1. `collect_module_units` discovers the import graph from the root, +//! registering every module (disk or in-memory override) in the +//! [`ModuleGraph`] and its raw text in the compilation-wide +//! [`SourceMap`](crate::compiler::source_map::SourceMap) at its graph +//! `SourceId`. +//! 2. Each module is parsed in module mode (implicit-extern fallback on): +//! calls the parser cannot resolve locally — imported module functions, +//! module namespace members, imported function values — parse into +//! synthetic externs (tracked on `FrontendIr::implicit_extern_names`) or +//! `Expr::UnresolvedFunctionRef`, and are resolved afterwards. +//! 3. `record_module_symbols` assigns every real declaration its owned +//! [`SymbolId`], fills the module's public export table and imported +//! binding table, then resolves every call site to an `Expr::ModuleCall` +//! or `Expr::ModuleFunctionRef` carrying the target symbol, validating +//! arity and type arguments against the exported signature. +//! 4. `linker::merge_units` merges units by symbol identity and applies the +//! deterministic flat-boundary mangling only there. +//! +//! Host namespace imports (`use io;`, `use myhost;`) never enter the textual +//! machinery: the parser keeps their dedicated host resolution path and the +//! loader records them as host/builtin import edges. Single-segment imports +//! that may name a file module (`use module;`) parse as host-form calls and +//! are fixed up by the loader when the spec resolves to a file module. +//! +//! Every span produced here references the owning module's graph `SourceId` +//! in the compilation-wide map, so diagnostics always render from the +//! owning source. + use std::path::Path; use crate::compiler::source_map::SourceMap; +use super::modules::ModuleGraph; use super::{ CompileSourceFileOptions, SourceError, SourceFlavor, SourcePathError, frontends, linker::ParsedUnit, @@ -9,68 +51,547 @@ use super::{ mod graph; mod imports; -mod line_map; mod model; -mod rewrite; -use graph::{build_rustscript_import_prelude, collect_module_units}; -use imports::{parse_module_imports, strip_import_directives}; -use line_map::remap_frontend_ir_line_numbers; +use graph::{collect_module_units, record_module_symbols}; +use imports::{module_identity, parse_module_imports, strip_import_directives}; use model::ModuleCollectState; pub use model::{FrontendImportSyntax, ImportClause, ModuleImport, NamedImport}; -use rewrite::rewrite_imported_call_sites; +pub(super) struct LoadedSourceUnits { + pub(super) units: Vec, + /// Semantic module graph built during discovery (milestones 1-3). + pub(super) module_graph: ModuleGraph, + /// Compilation-wide source map keyed by the module graph's `SourceId` + /// space (milestone 5). Every module's raw text is registered here at + /// its graph source id, so spans carried by the loaded units and by any + /// load-time diagnostic resolve to the owning source. + pub(super) sources: SourceMap, +} pub(super) fn load_units_for_source_file( path: &Path, flavor: SourceFlavor, source_raw: &str, options: &CompileSourceFileOptions, -) -> Result<(String, Vec), SourcePathError> { - let root_imports = parse_module_imports(source_raw, flavor, path, options)?; - let source = strip_import_directives(source_raw, flavor, options)?; +) -> Result { + // The root participates in the same identity scheme as every module: + // canonical disk identity when the file exists, normalized virtual + // identity otherwise. This keeps `seen`/`visiting`/exports/overrides + // keyed uniformly across the whole import graph. + let path = module_identity(path.to_path_buf()); + let path = path.as_path(); let mut collect_state = ModuleCollectState::default(); + // Pre-register the root text at its graph source id. The root node is + // always registered first (SourceId(0)); registering the text here lets + // the root's own scan/parse diagnostics attach spans against the + // compilation-wide map before collection runs. + collect_state + .sources + .add_source_at(0, path.display().to_string(), source_raw.to_string()); collect_state.visiting.push(path.to_path_buf()); - collect_module_units(path, source_raw, flavor, options, &mut collect_state)?; - let rewritten_root = rewrite_imported_call_sites( - &source, + let root_imports = parse_module_imports(source_raw, flavor, path, options).map_err(|err| { + // The root's own scan/parse diagnostics attach their span against + // the pre-registered root source and carry the compilation-wide map, + // so they render from the root's text. + match err { + SourcePathError::Source(SourceError::Parse(mut parse)) => { + parse.span = None; + parse = parse.with_line_span_from_source(&collect_state.sources, 0); + SourcePathError::SourceWithMap { + error: SourceError::Parse(parse), + sources: collect_state.sources.clone(), + } + } + other => other, + } + })?; + + collect_module_units(path, source_raw, flavor, options, &mut collect_state).map_err(|err| { + // Load-time source diagnostics (nested scan/parse errors, symbol + // resolution, imported-call resolution) already carry spans keyed to + // the compilation-wide map; attach the map so they render from the + // owning source. + match err { + SourcePathError::Source(error) => SourcePathError::SourceWithMap { + error, + sources: collect_state.sources.clone(), + }, + other => other, + } + })?; + let root_module = collect_state + .module_graph + .module_id_for_identity(path) + .expect("root module should be registered in the module graph"); + let root_source_id = collect_state + .module_graph + .node(root_module) + .map(|node| node.source.0) + .unwrap_or(0); + let root_parse_source = strip_import_directives(source_raw, flavor, options)?; + + let mut root_parsed = frontends::parse_module_source_with_source_id( + &root_parse_source, flavor, - path, - &root_imports, - &collect_state.module_exports, options, - )?; - let mut prelude = build_rustscript_import_prelude( + root_source_id, + ) + .map_err(|mut err| { + // Module sources are parsed verbatim (no synthetic prelude, no + // textual rewrite), so parse lines already refer to the owning + // source; rebuild the span against the compilation-wide map so the + // diagnostic renders from the root's text. + err.span = None; + let parse = err.with_line_span_from_source(&collect_state.sources, root_source_id); + SourcePathError::SourceWithMap { + error: SourceError::Parse(parse), + sources: collect_state.sources.clone(), + } + })?; + record_module_symbols( + &mut collect_state, + root_module, path, &root_imports, - &collect_state.module_exports, + &mut root_parsed, options, - )?; - let root_prelude_lines = prelude.lines().count(); - prelude.push_str(&rewritten_root.source); - let root_parse_source = prelude; - - let mut root_source_map = SourceMap::new(); - let root_source_id = root_source_map.add_source(path.display().to_string(), source_raw); - let mut root_parsed = frontends::parse_source(&root_parse_source, flavor, options) - .map_err(|mut err| { - if root_prelude_lines > 0 { - err.line = err.line.saturating_sub(root_prelude_lines).max(1); - // Reattach span against original source text for diagnostics. - err.span = None; - } - SourceError::Parse(err.with_line_span_from_source(&root_source_map, root_source_id)) - }) - .map_err(SourcePathError::Source)?; - if root_prelude_lines > 0 { - remap_frontend_ir_line_numbers(&mut root_parsed, root_prelude_lines); - } + ) + .map_err(|err| match err { + // Root resolution diagnostics (unknown/ambiguous imported calls, + // visibility failures) already carry spans keyed to the + // compilation-wide map; attach the map so they render from the + // owning source. + SourcePathError::Source(error) => SourcePathError::SourceWithMap { + error, + sources: collect_state.sources.clone(), + }, + other => other, + })?; collect_state.units.push(ParsedUnit { parsed: root_parsed, - scope_prefix: None, + scope_identity: None, source_name: path.display().to_string(), + module: root_module, + source_id: root_source_id, }); - Ok((root_parse_source, collect_state.units)) + Ok(LoadedSourceUnits { + units: collect_state.units, + module_graph: collect_state.module_graph, + sources: collect_state.sources, + }) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::super::modules::ModuleId; + use super::*; + + fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + // Module identities are canonical for existing files; keep expected + // paths canonical too so assertions match under symlinked temp dirs. + root.canonicalize().unwrap_or(root) + } + + fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source) + .unwrap_or_else(|err| panic!("{description} should write: {err}")); + } + + fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); + } + + /// Two modules named `util.rss` in different directories plus a root that + /// imports both. Returns `(main, a/util, b/util)` paths. + fn write_same_stem_fixture(root: &Path) -> (PathBuf, PathBuf, PathBuf) { + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + + let a_module = a_dir.join("util.rss"); + let b_module = b_dir.join("util.rss"); + write_source(&a_module, "pub fn helper() { 1; }\n", "a/util source"); + write_source(&b_module, "pub fn helper() { 2; }\n", "b/util source"); + + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nfn run() { au::helper(); bu::helper(); }\n", + "main source", + ); + (main_path, a_module, b_module) + } + + #[test] + fn loader_graph_records_same_stem_modules_in_different_directories() { + let root = temp_module_root("semantic_m1_same_stem"); + let (main_path, a_module, b_module) = write_same_stem_fixture(&root); + let main_source = std::fs::read_to_string(&main_path).expect("main source readable"); + + let loaded = load_units_for_source_file( + &main_path, + SourceFlavor::RustScript, + &main_source, + &CompileSourceFileOptions::default(), + ) + .expect("load should succeed"); + let graph = loaded.module_graph; + assert_eq!(graph.len(), 3, "root plus two same-stem modules"); + + let main_id = graph + .module_id_for_identity(&main_path) + .expect("main module should be registered"); + let a_id = graph + .module_id_for_identity(&a_module) + .expect("a/util should be registered"); + let b_id = graph + .module_id_for_identity(&b_module) + .expect("b/util should be registered"); + assert_ne!( + a_id, b_id, + "same-stem modules in different dirs must differ" + ); + assert_eq!(main_id, ModuleId(0), "root module is always module 0"); + + // Import edges from the root to both modules, in source order. + let main_node = graph.node(main_id).expect("main node should exist"); + assert_eq!(main_node.imports.len(), 2); + let targets: Vec<_> = main_node + .imports + .iter() + .map(|import| import.target) + .collect(); + assert!(targets.contains(&Some(a_id))); + assert!(targets.contains(&Some(b_id))); + assert!(main_node.imports.iter().all(|import| import.line >= 1)); + assert_eq!(main_node.imports[0].spec, "a/util.rss"); + assert_eq!(main_node.imports[1].spec, "b/util.rss"); + + remove_module_root(&root); + } + + #[test] + fn loader_graph_is_deterministic_across_loads() { + let root = temp_module_root("semantic_m1_deterministic"); + let (main_path, _, _) = write_same_stem_fixture(&root); + + let load = || { + let source = std::fs::read_to_string(&main_path).expect("main source readable"); + load_units_for_source_file( + &main_path, + SourceFlavor::RustScript, + &source, + &CompileSourceFileOptions::default(), + ) + .expect("load should succeed") + .module_graph + }; + let first = load(); + let second = load(); + assert_eq!(first.len(), second.len()); + let sequence = |graph: &ModuleGraph| { + graph + .nodes() + .iter() + .map(|node| (node.module, node.identity.clone())) + .collect::>() + }; + assert_eq!(sequence(&first), sequence(&second)); + + remove_module_root(&root); + } + + #[test] + fn loader_graph_uses_virtual_identity_for_in_memory_modules() { + let path = PathBuf::from("__pd_vm_inmemory__/main.rss"); + let source = "use a::util;\nfn run() { helper(); }\n"; + let options = CompileSourceFileOptions::new() + .with_module_override_source("a/util.rss", "pub fn helper() { 1; }\n"); + + let loaded = load_units_for_source_file(&path, SourceFlavor::RustScript, source, &options) + .expect("virtual load should succeed"); + let graph = loaded.module_graph; + assert_eq!(graph.len(), 2, "virtual root plus overridden module"); + + let main_id = graph + .module_id_for_identity(&path) + .expect("virtual main should be registered"); + let a_id = graph + .module_id_for_identity(PathBuf::from("__pd_vm_inmemory__/a/util.rss").as_path()) + .expect("virtual override module should be registered"); + assert_ne!(main_id, a_id); + + let main_node = graph.node(main_id).expect("main node should exist"); + assert_eq!(main_node.imports.len(), 1); + assert_eq!(main_node.imports[0].target, Some(a_id)); + assert_eq!( + main_node.imports[0].kind, + super::super::modules::ImportTargetKind::FileModule + ); + } + + /// Fixture: `main` imports `a/util` (pub alpha + private helper) and + /// `b/util` (pub beta + private helper). Both helpers are private and + /// same-named; `a/util` also imports a third module `leaf` (pub shared) + /// so the transitive re-export rule is exercised through the real loader. + fn write_symbol_fixture(root: &Path) -> (PathBuf, PathBuf, PathBuf, PathBuf) { + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + + let leaf_module = a_dir.join("leaf.rss"); + write_source(&leaf_module, "pub fn shared() { 100; }\n", "leaf source"); + + let a_module = a_dir.join("util.rss"); + write_source( + &a_module, + "use self::leaf;\npub fn alpha() { helper(); }\nfn helper() { 11; }\n", + "a/util source", + ); + let b_module = b_dir.join("util.rss"); + write_source( + &b_module, + "pub fn beta() { helper(); }\nfn helper() { 22; }\n", + "b/util source", + ); + + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nfn run() { au::alpha(); bu::beta(); }\n", + "main source", + ); + (main_path, a_module, b_module, leaf_module) + } + + fn load_fixture(main_path: &Path) -> ModuleGraph { + let main_source = std::fs::read_to_string(main_path).expect("main source readable"); + load_units_for_source_file( + main_path, + SourceFlavor::RustScript, + &main_source, + &CompileSourceFileOptions::default(), + ) + .expect("load should succeed") + .module_graph + } + + #[test] + fn loader_records_public_exports_and_private_declarations() { + let root = temp_module_root("semantic_m3_exports"); + let (main_path, a_module, b_module, _) = write_symbol_fixture(&root); + let graph = load_fixture(&main_path); + + let a_id = graph + .module_id_for_identity(&a_module) + .expect("a/util should be registered"); + let a_node = graph.node(a_id).expect("a/util node exists"); + let a_names = |node: &super::super::modules::ModuleNode| { + node.declarations + .iter() + .map(|decl| decl.name.clone()) + .collect::>() + }; + assert_eq!(a_names(a_node), vec!["alpha", "helper"]); + assert_eq!( + a_node + .exports + .iter() + .map(|entry| entry.name.as_str()) + .collect::>(), + vec!["alpha"], + "only the pub declaration is exported" + ); + assert!( + !graph + .declaration(a_id, "helper") + .expect("private helper exists") + .public + ); + assert_eq!( + graph.symbol_for_export(a_id, "helper"), + None, + "private helpers never enter the export table" + ); + + let b_id = graph + .module_id_for_identity(&b_module) + .expect("b/util should be registered"); + assert_eq!( + graph + .symbol_for_export(b_id, "beta") + .expect("beta is exported") + .module, + b_id, + "each module's exports are owned by that module" + ); + + remove_module_root(&root); + } + + #[test] + fn loader_keeps_imported_bindings_separate_and_blocks_transitive_reexport() { + let root = temp_module_root("semantic_m3_bindings"); + let (main_path, a_module, _, leaf_module) = write_symbol_fixture(&root); + let graph = load_fixture(&main_path); + + let a_id = graph + .module_id_for_identity(&a_module) + .expect("a/util should be registered"); + let a_node = graph.node(a_id).expect("a/util node exists"); + assert_eq!( + a_node.imported_bindings.len(), + 1, + "a/util imports exactly leaf::shared" + ); + let binding = &a_node.imported_bindings[0]; + assert_eq!(binding.local_name, "shared"); + assert_eq!(binding.source_name, "shared"); + assert_eq!( + binding.source_module, + graph + .module_id_for_identity(&leaf_module) + .expect("leaf should be registered") + ); + assert_eq!( + graph + .symbol_for_export(a_id, "shared") + .map(|symbol| symbol.module), + None, + "a/util must not re-export leaf's function" + ); + assert!( + a_node.declarations.iter().all(|decl| decl.name != "shared"), + "the imported function is not a local declaration of a/util" + ); + + let main_id = graph + .module_id_for_identity(&main_path) + .expect("main should be registered"); + let main_node = graph.node(main_id).expect("main node exists"); + assert_eq!( + main_node.imported_bindings.len(), + 2, + "main imports alpha and beta" + ); + assert!( + main_node + .imported_bindings + .iter() + .all(|binding| binding.local_name == "alpha" || binding.local_name == "beta") + ); + assert!( + main_node.imported_bindings.iter().all(|binding| { + graph + .symbol_for_export(main_id, &binding.local_name) + .is_none() + }), + "main's export table stays empty: no implicit re-export of anything imported" + ); + + remove_module_root(&root); + } + + #[test] + fn loader_assigns_distinct_symbols_to_same_named_private_helpers() { + let root = temp_module_root("semantic_m3_same_named"); + let (main_path, a_module, b_module, _) = write_symbol_fixture(&root); + let graph = load_fixture(&main_path); + + let a_id = graph + .module_id_for_identity(&a_module) + .expect("a/util should be registered"); + let b_id = graph + .module_id_for_identity(&b_module) + .expect("b/util should be registered"); + let a_helper = graph + .declaration_symbol(a_id, "helper") + .expect("a helper exists"); + let b_helper = graph + .declaration_symbol(b_id, "helper") + .expect("b helper exists"); + assert_ne!( + a_helper, b_helper, + "same-named private helpers in independent modules own distinct symbols" + ); + assert_eq!(a_helper.module, a_id); + assert_eq!(b_helper.module, b_id); + + let main_id = graph + .module_id_for_identity(&main_path) + .expect("main should be registered"); + let main_alpha = graph + .declaration_symbol(main_id, "run") + .expect("run exists"); + assert_eq!( + main_alpha, + super::super::modules::SymbolId { + module: main_id, + index: 0 + }, + "root declarations start at symbol index 0" + ); + + remove_module_root(&root); + } + + #[test] + fn loader_symbols_are_deterministic_across_loads() { + let root = temp_module_root("semantic_m3_symbol_determinism"); + let (main_path, a_module, _, _) = write_symbol_fixture(&root); + let first = load_fixture(&main_path); + let second = load_fixture(&main_path); + + let symbol_sequence = |graph: &ModuleGraph| { + graph + .nodes() + .iter() + .map(|node| { + ( + node.module, + node.declarations + .iter() + .map(|decl| (decl.name.clone(), decl.symbol)) + .collect::>(), + node.exports + .iter() + .map(|entry| (entry.name.clone(), entry.symbol)) + .collect::>(), + node.imported_bindings + .iter() + .map(|binding| { + ( + binding.local_name.clone(), + binding.source_symbol, + binding.source_module, + ) + }) + .collect::>(), + ) + }) + .collect::>() + }; + assert_eq!(symbol_sequence(&first), symbol_sequence(&second)); + assert_eq!(first.len(), second.len()); + let _ = a_module; + + remove_module_root(&root); + } } diff --git a/src/compiler/source_loader/graph.rs b/src/compiler/source_loader/graph.rs index 82e95c30..a997a1ec 100644 --- a/src/compiler/source_loader/graph.rs +++ b/src/compiler/source_loader/graph.rs @@ -1,16 +1,18 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use crate::compiler::source_map::SourceMap; use super::super::{ - CompileSourceFileOptions, SourceError, SourceFlavor, SourcePathError, frontends, - linker::{ParsedUnit, sanitize_scope_prefix}, + CompileSourceFileOptions, ParseError, SourceError, SourceFlavor, SourcePathError, frontends, + ir::{Expr, FrontendIr, FunctionDecl, Stmt, TypeSchema}, + linker::{ParsedUnit, module_scope_prefix}, + modules::{ImportTargetKind, ImportedBinding, ModuleGraph, ModuleId, ResolvedImport, SymbolId}, }; use super::imports::{ is_builtin_host_namespace_spec, is_module_specifier, is_virtual_host_namespace_spec, - parse_module_imports, resolve_module_path, should_treat_missing_module_as_host_namespace, - strip_import_directives, + parse_module_imports, resolve_module_path, scan_module_imports, + should_treat_missing_module_as_host_namespace, }; use super::model::{ExportedFunctionSignature, ImportClause, ModuleCollectState, ModuleImport}; @@ -21,13 +23,79 @@ pub(super) fn collect_module_units( options: &CompileSourceFileOptions, state: &mut ModuleCollectState, ) -> Result<(), SourcePathError> { - let imports = parse_module_imports(source, flavor, path, options)?; - for import in imports { - let spec = import.spec; + // Register this module in the semantic graph. Registration is + // identity-keyed and idempotent, so the root and every nested module get + // a deterministic `ModuleId`/`SourceId` in first-encounter order. + let current_id = + state + .module_graph + .add_node(path.to_path_buf(), path.display().to_string(), Vec::new()); + // Register the module's raw text in the compilation-wide source map at + // its graph `SourceId`, before any scan or parse that can produce spans + // referencing that id (milestone 5: every span stays owned by its + // module's source). + let current_source_id = state + .module_graph + .node(current_id) + .map(|node| node.source) + .unwrap_or(crate::compiler::modules::SourceId(0)); + state.sources.add_source_at( + current_source_id.0, + path.display().to_string(), + source.to_string(), + ); + let (imports, decls) = scan_module_imports(source, flavor, path, options).map_err(|err| { + // Nested module sources surface their parse errors through the same + // path-prefixed diagnostic shape the compile parse uses. The root is + // scanned (and fails, if at all) in `load_units_for_source_file` + // before this point, so it never receives a prefix here. The scan + // parser numbers spans with its own local source id 0, so the span + // is always rebuilt against the owning module's graph source id — + // offsets from one module must never be interpreted in another. + match err { + SourcePathError::Source(SourceError::Parse(mut parse)) => { + parse.message = format!("{}: {}", path.display(), parse.message); + parse.span = None; + parse = parse.with_line_span_from_source(&state.sources, current_source_id.0); + SourcePathError::Source(SourceError::Parse(parse)) + } + other => other, + } + })?; + for (import_index, import) in imports.iter().enumerate() { + let spec = import.spec.clone(); + let span = decls + .get(import_index) + .map(|decl| decl.span) + .unwrap_or_else(|| crate::compiler::source_map::Span::new(0, 0, 0)); if is_builtin_host_namespace_spec(&spec) { + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::BuiltinNamespace, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target: None, + }, + ); continue; } if !is_module_specifier(&spec) { + // Plugin-managed host imports (non-RustScript flavors) stay on + // their dedicated resolution path. + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::HostNamespace, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target: None, + }, + ); continue; } let resolved = resolve_module_path(path, &spec, options)?; @@ -35,12 +103,37 @@ pub(super) fn collect_module_units( if key == path && is_virtual_host_namespace_spec(&spec, options) { // `use io;` / `use re;` inside files named `io.rss` / `re.rss` should // keep behaving as host-namespace imports instead of self-module cycles. + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::HostNamespace, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target: None, + }, + ); continue; } if state.visiting.contains(&key) { return Err(SourcePathError::ImportCycle(key)); } if state.seen.contains(&key) { + // Already loaded: keep the resolved edge pointing at the existing + // node instead of re-collecting the module. + let target = state.module_graph.module_id_for_identity(&key); + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::FileModule, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target, + }, + ); continue; } @@ -52,6 +145,17 @@ pub(super) fn collect_module_units( Ok(source) => source, Err(err) => { if should_treat_missing_module_as_host_namespace(&spec, options, &err) { + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::HostNamespace, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target: None, + }, + ); continue; } return Err(SourcePathError::Io(err)); @@ -68,22 +172,43 @@ pub(super) fn collect_module_units( )?; state.visiting.pop(); - let module_source = - strip_import_directives(&module_source_raw, SourceFlavor::RustScript, options)?; - let mut module_source_map = SourceMap::new(); - let module_source_id = - module_source_map.add_source(resolved.display().to_string(), module_source.clone()); - let parsed = frontends::parse_source(&module_source, SourceFlavor::RustScript, options) - .map_err(|err| { - SourceError::Parse( - err.with_line_span_from_source(&module_source_map, module_source_id), - ) - }) - .map_err(SourcePathError::Source)?; + let module_imports = parse_module_imports( + &module_source_raw, + SourceFlavor::RustScript, + &resolved, + options, + )?; + let module_source_id = state + .module_graph + .module_id_for_identity(&key) + .and_then(|module| state.module_graph.node(module)) + .map(|node| node.source.0) + .unwrap_or(0); + let mut parsed = frontends::parse_module_source_with_source_id( + &module_source_raw, + SourceFlavor::RustScript, + options, + module_source_id, + ) + .map_err(|mut err| { + // Nested module sources are parsed verbatim (no synthetic + // prelude, no textual rewrite), so the parse already reports the + // owning module's lines; rebuild the span against the + // compilation-wide map and prefix the module path. + err.span = None; + let mut parse = err.with_line_span_from_source(&state.sources, module_source_id); + parse.message = format!("{}: {}", resolved.display(), parse.message); + SourceError::Parse(parse) + })?; + let extern_names = parsed + .implicit_extern_names + .iter() + .map(String::as_str) + .collect::>(); let exports = parsed .functions .iter() - .filter(|func| func.exported) + .filter(|func| func.exported && !extern_names.contains(func.name.as_str())) .map(|func| { ( func.name.clone(), @@ -94,11 +219,36 @@ pub(super) fn collect_module_units( ) }) .collect::>(); + let target = state + .module_graph + .module_id_for_identity(&key) + .expect("module node should be registered during collection"); + record_module_symbols( + state, + target, + &resolved, + &module_imports, + &mut parsed, + options, + )?; state.units.push(ParsedUnit { parsed, - scope_prefix: Some(sanitize_scope_prefix(&resolved)), + scope_identity: Some(module_scope_prefix(&resolved, target)), source_name: resolved.display().to_string(), + module: target, + source_id: module_source_id, }); + state.module_graph.add_import( + current_id, + ResolvedImport { + kind: ImportTargetKind::FileModule, + spec, + clause: import.clause.clone(), + span, + line: import.line, + target: Some(target), + }, + ); state.module_exports.insert(key.clone(), exports); state.seen.insert(key); } @@ -115,37 +265,125 @@ fn module_source_override<'a>( }) } -pub(super) fn build_rustscript_import_prelude( - path: &Path, - imports: &[ModuleImport], - module_exports: &HashMap>, - options: &CompileSourceFileOptions, -) -> Result { - let declared = collect_imported_module_functions(path, imports, module_exports, options)?; - let mut prelude = String::new(); - for (name, signature) in declared { - let type_params = if signature.type_params.is_empty() { - String::new() - } else { - format!("<{}>", signature.type_params.join(", ")) +/// Build the exported-signature table keyed by [`SymbolId`]. +/// +/// The loader validates call sites against the exported arity and type +/// parameters at resolution time (the parse can no longer see them: module +/// sources are parsed verbatim without a synthetic prelude). +fn exported_signature_table( + state: &ModuleCollectState, + graph: &ModuleGraph, +) -> HashMap { + let mut table = HashMap::new(); + for node in graph.nodes() { + let Some(exports) = state.module_exports.get(&node.identity) else { + continue; }; - let args = (0..signature.arity) - .map(|idx| format!("arg{idx}")) - .collect::>() - .join(", "); - prelude.push_str(&format!("pub fn {name}{type_params}({args});\n")); + for entry in &node.exports { + if let Some(signature) = exports.get(&entry.name) { + table.insert(entry.symbol, signature.clone()); + } + } + } + table +} + +/// Namespace portion of a qualified call name (`au::helper` → `au`). +fn namespace_of(qualified: &str) -> &str { + qualified + .split_once("::") + .map(|(namespace, _)| namespace) + .unwrap_or(qualified) +} + +/// Clause-derived namespace alias of one import edge, mirroring the parser's +/// module-namespace alias rules: the `as` alias for namespace imports, the +/// spec stem for all-public imports, and no namespace for named imports. +fn namespace_alias_for_import(import: &ResolvedImport) -> Option { + match &import.clause { + ImportClause::Namespace(alias) => Some(alias.clone()), + ImportClause::AllPublic => Path::new(&import.spec) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(|stem| stem.to_string()), + ImportClause::Named(_) | ImportClause::Prefix(_) => None, } - Ok(prelude) } -pub(super) fn collect_imported_module_functions( +/// File-module import targets that bind `namespace`, either through a clause +/// alias (`use a::util as au;` binds `au`) or through the spec stem +/// (host-form single-segment imports such as `use module;` whose namespace +/// the parser resolved as a host root). +fn file_module_targets_for_namespace( + graph: &ModuleGraph, + module: ModuleId, + namespace: &str, +) -> Vec { + let Some(node) = graph.node(module) else { + return Vec::new(); + }; + let mut targets = Vec::new(); + for import in &node.imports { + if import.kind != ImportTargetKind::FileModule { + continue; + } + let Some(target) = import.target else { + continue; + }; + let stem = Path::new(&import.spec) + .file_stem() + .and_then(|stem| stem.to_str()); + if (namespace_alias_for_import(import).as_deref() == Some(namespace) + || stem == Some(namespace)) + && !targets.contains(&target) + { + targets.push(target); + } + } + targets +} + +/// Whether any file-module import edge of `module` binds `qualified`'s +/// namespace. Host-form declarations whose namespace names a file module are +/// kept out of the module's declaration table; the resolution pass converts +/// their call sites to [`Expr::ModuleCall`] instead. +fn namespace_has_file_module_target( + graph: &ModuleGraph, + module: ModuleId, + qualified: &str, +) -> bool { + !file_module_targets_for_namespace(graph, module, namespace_of(qualified)).is_empty() +} + +/// One function binding introduced by an import edge, before it is recorded +/// in the module graph. +struct ImportBindingData { + /// Name the importing module binds (`as` alias for named imports). + local_name: String, + /// Name of the declaration in the source module. + source_name: String, + /// Source module once its graph node is known; `None` for host/builtin + /// namespaces that stay on their dedicated resolution paths. + source_module: Option, + /// Line of the `use` directive that introduced the binding. + line: usize, +} + +/// Collect the function bindings a module's imports introduce, structurally. +/// +/// Mirrors the legacy `collect_imported_module_functions` resolution rules +/// (builtin and non-module specifiers skipped, missing virtual host namespaces +/// tolerated) but preserves `as` aliases and resolves the source module in +/// the semantic graph, so the loader can record [`ImportedBinding`]s that +/// stay separate from local declarations. +fn collect_imported_bindings( path: &Path, imports: &[ModuleImport], module_exports: &HashMap>, + graph: &ModuleGraph, options: &CompileSourceFileOptions, -) -> Result, SourcePathError> { - let mut imported_functions = HashMap::::new(); - +) -> Result, SourcePathError> { + let mut bindings = Vec::new(); for import in imports { if is_builtin_host_namespace_spec(&import.spec) { continue; @@ -165,22 +403,22 @@ pub(super) fn collect_imported_module_functions( message: format!("module '{}' did not load", import.spec), }); }; + let source_module = graph.module_id_for_identity(&resolved); match &import.clause { ImportClause::AllPublic | ImportClause::Namespace(_) | ImportClause::Prefix(_) => { - for (name, signature) in exports { - merge_imported_function_signature( - &mut imported_functions, - name, - signature, - path, - import.line, - )?; + for name in exports.keys() { + bindings.push(ImportBindingData { + local_name: name.clone(), + source_name: name.clone(), + source_module, + line: import.line, + }); } } ImportClause::Named(named) => { for binding in named { - let signature = exports.get(&binding.imported).cloned().ok_or_else(|| { + let _signature = exports.get(&binding.imported).cloned().ok_or_else(|| { SourcePathError::InvalidImportSyntax { path: path.to_path_buf(), line: import.line, @@ -190,48 +428,796 @@ pub(super) fn collect_imported_module_functions( ), } })?; - merge_imported_function_signature( - &mut imported_functions, - &binding.imported, - &signature, - path, - import.line, - )?; + bindings.push(ImportBindingData { + local_name: binding.local.clone(), + source_name: binding.imported.clone(), + source_module, + line: import.line, + }); } } } } - - let mut declared = imported_functions.into_iter().collect::>(); - declared.sort_by(|(lhs_name, _), (rhs_name, _)| lhs_name.cmp(rhs_name)); - Ok(declared) + Ok(bindings) } -fn merge_imported_function_signature( - imported_functions: &mut HashMap, - name: &str, - signature: &ExportedFunctionSignature, +/// Attach milestone-3 declaration symbols and imported bindings to a parsed +/// unit's module node, then resolve imported call sites to their target +/// symbols (milestone 4). +/// +/// Runs once per module, right after its unit is parsed: imported-binding +/// mirror declarations and implicit externs are skipped, every remaining +/// function declaration receives a [`SymbolId`] owned by the module, public +/// declarations populate the module's export table, and every import-introduced +/// binding is recorded separately in the module's imported-binding table. +/// The same `symbol` is written back onto the parsed [`FunctionDecl`] so the +/// linker can collect it through `merge_units`. Finally, calls to imported +/// functions and module namespace members are resolved to [`Expr::ModuleCall`] +/// nodes carrying the target [`SymbolId`]. +pub(super) fn record_module_symbols( + state: &mut ModuleCollectState, + module: ModuleId, path: &Path, - line: usize, + imports: &[ModuleImport], + parsed: &mut FrontendIr, + options: &CompileSourceFileOptions, ) -> Result<(), SourcePathError> { - if let Some(existing) = imported_functions.get_mut(name) { - existing.arity = existing.arity.max(signature.arity); - if existing.type_params != signature.type_params { - if existing.type_params.is_empty() { - existing.type_params = signature.type_params.clone(); - } else if !signature.type_params.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, + let bindings = collect_imported_bindings( + path, + imports, + &state.module_exports, + &state.module_graph, + options, + )?; + // Implicit externs (module mode) mirror calls the loader must resolve or + // reject; they never become local declarations or flat entries. + let extern_names = parsed + .implicit_extern_names + .iter() + .cloned() + .collect::>(); + + let module_source_id = state + .module_graph + .node(module) + .map(|node| node.source.0) + .unwrap_or(0); + let decl_lines = collect_function_decl_lines(&parsed.stmts); + let signatures = exported_signature_table(state, &state.module_graph); + + for func in &mut parsed.functions { + if extern_names.contains(func.name.as_str()) { + // Implicit extern (module mode): the resolution pass resolves + // (or rejects) its call sites; never a local decl. + continue; + } + if func.name.contains("::") + && namespace_has_file_module_target(&state.module_graph, module, &func.name) + { + // Host-form declaration whose namespace names a file module + // (single-segment import forms such as `use module;`): the + // resolution pass converts its call sites to `ModuleCall`, so + // the declaration must not become a flat host entry. + continue; + } + // A local declaration whose name is also imported is recorded here + // and then rejected when the import binding is added below: no + // silent shadowing of imported names. + let decl_line = decl_lines + .get(&func.index) + .copied() + .map(|line| line as usize) + .unwrap_or(1); + let symbol = state + .module_graph + .add_declaration(module, &func.name, func.exported) + .map_err(|message| { + // The duplicate symbol diagnostic renders from the owning + // module source: same-named declarations collide inside one + // module only, and the span points at the redeclaration. + let span = state.sources.line_span(module_source_id, decl_line); + SourcePathError::Source(SourceError::Parse(ParseError { + span, + code: None, + line: decl_line, + message: format!("{}: {message}", path.display()), + })) + })?; + func.symbol = Some(symbol); + } + + for binding in bindings { + let Some(source_module) = binding.source_module else { + continue; + }; + let binding_line = binding.line.max(1); + let source_symbol = state + .module_graph + .symbol_for_export(source_module, &binding.source_name) + .ok_or_else(|| { + // Visibility failure: the import directive is the offending + // site, so the span points at the `use` line in the + // importing module's source. + let span = state.sources.line_span(module_source_id, binding_line); + SourcePathError::Source(SourceError::Parse(ParseError { + span, + code: None, + line: binding_line, message: format!( - "function '{name}' declared with conflicting type parameters across imported modules" + "{}: imported function '{}' is not exported by module {}", + path.display(), + binding.source_name, + source_module.0 ), - }); + })) + })?; + state + .module_graph + .add_imported_binding( + module, + ImportedBinding { + local_name: binding.local_name, + source_module, + source_symbol, + source_name: binding.source_name, + }, + ) + .map_err(|message| { + let span = state.sources.line_span(module_source_id, binding_line); + SourcePathError::Source(SourceError::Parse(ParseError { + span, + code: None, + line: binding_line, + message: format!("{}: {message}", path.display()), + })) + })?; + } + + resolve_imported_call_sites( + module, + path, + &state.module_graph, + &state.sources, + &signatures, + &extern_names, + parsed, + ) +} + +fn collect_function_decl_lines(stmts: &[Stmt]) -> HashMap { + let mut lines = HashMap::new(); + record_function_decl_lines(stmts, &mut lines); + lines +} + +fn record_function_decl_lines(stmts: &[Stmt], lines: &mut HashMap) { + for stmt in stmts { + match stmt { + Stmt::FuncDecl { index, line, .. } => { + lines.entry(*index).or_insert(*line); + } + Stmt::IfElse { + then_branch, + else_branch, + .. + } => { + record_function_decl_lines(then_branch, lines); + record_function_decl_lines(else_branch, lines); + } + Stmt::For { + init, post, body, .. + } => { + record_function_decl_lines(std::slice::from_ref(init.as_ref()), lines); + record_function_decl_lines(std::slice::from_ref(post.as_ref()), lines); + record_function_decl_lines(body, lines); + } + Stmt::While { body, .. } => record_function_decl_lines(body, lines), + _ => {} + } + } +} + +/// Resolution context for one module's imported-call pass. +struct CallResolutionContext<'a> { + functions_by_index: HashMap, + /// Direct call names bound by exactly one source module (keyed by the + /// name the importing module binds: `as` alias or source name). + plain_symbols: HashMap, + /// Direct call names bound from several modules with different symbols. + ambiguous_names: HashSet, + /// Exported arity/type-parameter table for signature validation. + signatures: &'a HashMap, + /// Implicit-extern names produced by the parser (module mode). + extern_names: &'a HashSet, + module: ModuleId, + path: &'a Path, + graph: &'a ModuleGraph, + sources: &'a SourceMap, + source_id: u32, +} + +impl<'a> CallResolutionContext<'a> { + /// Resolve one unit-local call name to its target symbol. + /// + /// Names with a namespace separator resolve through the module's + /// file-module import edges (clause alias or spec stem); plain names + /// resolve through the imported-binding table. Returns `Ok(None)` for + /// names that are neither (the caller decides how to report them). + fn target_for_call( + &self, + decl_name: &str, + arg_count: usize, + type_args: &[TypeSchema], + line: u32, + ) -> Result, SourcePathError> { + if let Some((namespace, member)) = decl_name.split_once("::") { + return self.target_for_namespace_call( + namespace, member, decl_name, arg_count, type_args, line, + ); + } + if self.ambiguous_names.contains(decl_name) { + return Err(ambiguous_imported_call_error( + self.path, + decl_name, + self.sources, + self.source_id, + line, + )); + } + if let Some(symbol) = self.plain_symbols.get(decl_name) { + self.validate_imported_signature(decl_name, *symbol, arg_count, type_args, line)?; + return Ok(Some(*symbol)); + } + Err(unknown_function_error( + self.path, + decl_name, + self.sources, + self.source_id, + line, + )) + } + + fn target_for_namespace_call( + &self, + namespace: &str, + member: &str, + qualified: &str, + arg_count: usize, + type_args: &[TypeSchema], + line: u32, + ) -> Result, SourcePathError> { + if member.contains("::") { + // Multi-level module member paths are not supported; the legacy + // pipeline reported the same call as an unknown namespace call. + return Err(unknown_namespace_call_error( + self.path, + qualified, + self.sources, + self.source_id, + line, + )); + } + let mut found = HashSet::new(); + for target in file_module_targets_for_namespace(self.graph, self.module, namespace) { + if let Some(symbol) = self.graph.symbol_for_export(target, member) { + found.insert(symbol); } } - return Ok(()); + match found.len() { + 0 => { + if self.extern_names.contains(qualified) { + // Multi-segment import form whose namespace or member did + // not resolve to a public export. + Err(unknown_namespace_call_error( + self.path, + qualified, + self.sources, + self.source_id, + line, + )) + } else { + // Host-form declaration through a file-module namespace + // whose module does not export the member: report like + // the legacy parse did for the unqualified name. + Err(unknown_function_error( + self.path, + member, + self.sources, + self.source_id, + line, + )) + } + } + 1 => { + let symbol = found.into_iter().next().expect("exactly one symbol"); + self.validate_imported_signature(qualified, symbol, arg_count, type_args, line)?; + Ok(Some(symbol)) + } + _ => Err(ambiguous_imported_call_error( + self.path, + qualified, + self.sources, + self.source_id, + line, + )), + } } - imported_functions.insert(name.to_string(), signature.clone()); + /// Validate a resolved call against the exported arity and type + /// parameters, mirroring the messages the synthetic prelude used to + /// produce at parse time. + fn validate_imported_signature( + &self, + call_name: &str, + symbol: SymbolId, + arg_count: usize, + type_args: &[TypeSchema], + line: u32, + ) -> Result<(), SourcePathError> { + let Some(signature) = self.signatures.get(&symbol) else { + return Ok(()); + }; + let parse_error = |message: String| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: self.sources.line_span(self.source_id, line as usize), + code: None, + line: line as usize, + message: format!("{}: {message}", self.path.display()), + })) + }; + if usize::from(signature.arity) != arg_count { + return Err(parse_error(format!( + "function '{call_name}' expects {} arguments", + signature.arity + ))); + } + if signature.type_params.is_empty() { + if type_args.is_empty() { + return Ok(()); + } + return Err(parse_error(format!( + "function '{call_name}' does not accept explicit type arguments" + ))); + } + if signature.type_params.len() != type_args.len() { + return Err(parse_error(format!( + "function '{call_name}' expects {} type arguments, got {}", + signature.type_params.len(), + type_args.len() + ))); + } + Ok(()) + } + + /// Resolve one function-value reference to its target symbol. + fn target_for_function_ref( + &self, + name: &str, + line: u32, + ) -> Result, SourcePathError> { + if self.ambiguous_names.contains(name) { + return Err(ambiguous_imported_call_error( + self.path, + name, + self.sources, + self.source_id, + line, + )); + } + if let Some(symbol) = self.plain_symbols.get(name) { + return Ok(Some(*symbol)); + } + Err(SourcePathError::Source(SourceError::Parse(ParseError { + span: self.sources.line_span(self.source_id, line as usize), + code: None, + line: line as usize, + message: format!("{}: unknown local '{}'", self.path.display(), name), + }))) + } +} + +/// Resolve every call to an imported function to its compiler-owned +/// [`SymbolId`] before unit merge. +/// +/// Module sources are parsed verbatim (no synthetic prelude, no textual +/// rewrite), so call sites reach this pass in the shapes the parser produced: +/// +/// - Direct calls (`helper(...)`) parse as implicit externs. A name bound +/// from exactly one source module maps to that module's symbol; a name +/// bound from several modules is ambiguous and becomes a diagnostic; an +/// unbound name is rejected as an unknown function. +/// - Namespace calls (`au::helper(...)`) parse either as implicit externs +/// carrying the qualified name (multi-segment import forms) or as +/// host-form calls whose namespace the parser treated as a host root +/// (single-segment import forms such as `use module;`). Both resolve +/// through the module's file-module import edges: the clause alias or the +/// spec stem maps the namespace to its target module, and the member must +/// be one of its public exports. +/// - Function values (`let f = helper;`) parse as +/// [`Expr::UnresolvedFunctionRef`] and resolve to +/// [`Expr::ModuleFunctionRef`]. +/// +/// Local calls (declarations that own a symbol) and host/builtin calls are +/// left untouched; the linker remaps them by symbol or keeps their reserved +/// builtin index. +fn resolve_imported_call_sites( + module: ModuleId, + path: &Path, + graph: &ModuleGraph, + sources: &SourceMap, + signatures: &HashMap, + extern_names: &HashSet, + parsed: &mut FrontendIr, +) -> Result<(), SourcePathError> { + let source_id = graph.node(module).map(|node| node.source.0).unwrap_or(0); + let mut plain_symbols = HashMap::::new(); + let mut ambiguous_names = HashSet::::new(); + if let Some(node) = graph.node(module) { + for binding in &node.imported_bindings { + match plain_symbols.entry(binding.local_name.clone()) { + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(binding.source_symbol); + } + std::collections::hash_map::Entry::Occupied(entry) + if *entry.get() != binding.source_symbol => + { + ambiguous_names.insert(binding.local_name.clone()); + } + std::collections::hash_map::Entry::Occupied(_) => {} + } + } + } + + let functions_by_index = parsed + .functions + .iter() + .map(|func| (func.index, func)) + .collect::>(); + + let ctx = CallResolutionContext { + functions_by_index, + plain_symbols, + ambiguous_names, + signatures, + extern_names, + module, + path, + graph, + sources, + source_id, + }; + + let resolve_stmt = |stmt: &mut Stmt| -> Result<(), SourcePathError> { + resolve_stmt_imported_calls(&ctx, stmt) + }; + for stmt in &mut parsed.stmts { + resolve_stmt(stmt)?; + } + for function_impl in parsed.function_impls.values_mut() { + for stmt in &mut function_impl.body_stmts { + resolve_stmt(stmt)?; + } + resolve_expr_imported_calls( + &ctx, + &mut function_impl.body_expr, + function_impl.body_expr_line.max(1), + )?; + } + Ok(()) +} + +fn unknown_function_error( + path: &Path, + name: &str, + sources: &SourceMap, + source_id: u32, + line: u32, +) -> SourcePathError { + SourcePathError::Source(SourceError::Parse(ParseError { + span: sources.line_span(source_id, line as usize), + code: None, + line: line as usize, + message: format!("{}: unknown function '{}'", path.display(), name), + })) +} + +/// Validate type arguments on a host import call whose parse-time validation +/// was deferred (non-builtin namespaces that may name file modules). +fn validate_deferred_host_type_args( + ctx: &CallResolutionContext<'_>, + host_name: &str, + type_args: &[TypeSchema], + line: u32, +) -> Result<(), SourcePathError> { + let expected = crate::compiler::parser::host_generic_type_arg_arity(host_name); + let parse_error = |message: String| { + SourcePathError::Source(SourceError::Parse(ParseError { + span: ctx.sources.line_span(ctx.source_id, line as usize), + code: None, + line: line as usize, + message: format!("{}: {message}", ctx.path.display()), + })) + }; + match expected { + Some(expected) if type_args.is_empty() || expected == type_args.len() => Ok(()), + Some(expected) => Err(parse_error(format!( + "function '{host_name}' expects {expected} type arguments, got {}", + type_args.len() + ))), + None if type_args.is_empty() => Ok(()), + None => Err(parse_error(format!( + "function '{host_name}' does not accept explicit type arguments" + ))), + } +} + +fn unknown_namespace_call_error( + path: &Path, + qualified: &str, + sources: &SourceMap, + source_id: u32, + line: u32, +) -> SourcePathError { + SourcePathError::Source(SourceError::Parse(ParseError { + span: sources.line_span(source_id, line as usize), + code: None, + line: line as usize, + message: format!( + "{}: unknown namespace call '{}'; the module does not export this function", + path.display(), + qualified + ), + })) +} + +fn ambiguous_imported_call_error( + path: &Path, + name: &str, + sources: &SourceMap, + source_id: u32, + line: u32, +) -> SourcePathError { + SourcePathError::Source(SourceError::Parse(ParseError { + span: sources.line_span(source_id, line as usize), + code: None, + line: line as usize, + message: format!( + "{}: call to '{name}' is ambiguous: the name is exported by multiple imported modules; qualify the call with a namespace alias or a named import", + path.display() + ), + })) +} + +fn resolve_stmt_imported_calls( + ctx: &CallResolutionContext<'_>, + stmt: &mut Stmt, +) -> Result<(), SourcePathError> { + let line = stmt_line(stmt); + match stmt { + Stmt::Noop { .. } | Stmt::Break { .. } | Stmt::Continue { .. } => {} + Stmt::Let { expr, .. } | Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { + resolve_expr_imported_calls(ctx, expr, line)?; + } + Stmt::ClosureLet { closure, .. } => { + resolve_expr_imported_calls(ctx, &mut closure.body, line)?; + } + Stmt::FuncDecl { .. } => {} + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + resolve_expr_imported_calls(ctx, condition, line)?; + for nested in then_branch { + resolve_stmt_imported_calls(ctx, nested)?; + } + for nested in else_branch { + resolve_stmt_imported_calls(ctx, nested)?; + } + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + resolve_stmt_imported_calls(ctx, init)?; + resolve_expr_imported_calls(ctx, condition, line)?; + resolve_stmt_imported_calls(ctx, post)?; + for nested in body { + resolve_stmt_imported_calls(ctx, nested)?; + } + } + Stmt::While { + condition, body, .. + } => { + resolve_expr_imported_calls(ctx, condition, line)?; + for nested in body { + resolve_stmt_imported_calls(ctx, nested)?; + } + } + Stmt::Drop { .. } => {} + } Ok(()) } + +fn resolve_expr_imported_calls( + ctx: &CallResolutionContext<'_>, + expr: &mut Expr, + line: u32, +) -> Result<(), SourcePathError> { + match expr { + Expr::Call(index, type_args, args) => { + for arg in args.iter_mut() { + resolve_expr_imported_calls(ctx, arg, line)?; + } + let Some(decl) = ctx.functions_by_index.get(index) else { + // Builtin calls use the reserved builtin index space and are + // not part of the unit's declaration table. + return Ok(()); + }; + if decl.symbol.is_some() { + // Local declaration or host import: resolved by the linker. + // Host imports whose type arguments were deferred at parse + // (non-builtin namespaces that may name file modules) are + // validated against the host generic arity here. + if decl.name.contains("::") { + validate_deferred_host_type_args(ctx, &decl.name, type_args, line)?; + } + return Ok(()); + } + let name = decl.name.as_str(); + if let Some(symbol) = ctx.target_for_call(name, args.len(), type_args, line)? { + *expr = Expr::ModuleCall(symbol, std::mem::take(type_args), std::mem::take(args)); + } else { + return Err(unknown_function_error( + ctx.path, + name, + ctx.sources, + ctx.source_id, + line, + )); + } + } + Expr::FunctionRef(index, _type_args) => { + let Some(decl) = ctx.functions_by_index.get(index) else { + return Ok(()); + }; + if decl.symbol.is_none() { + return Err(unknown_function_error( + ctx.path, + &decl.name, + ctx.sources, + ctx.source_id, + line, + )); + } + } + Expr::UnresolvedFunctionRef { name, type_args } => { + if let Some(symbol) = ctx.target_for_function_ref(name, line)? { + *expr = Expr::ModuleFunctionRef(symbol, std::mem::take(type_args)); + } else { + return Err(unknown_function_error( + ctx.path, + name, + ctx.sources, + ctx.source_id, + line, + )); + } + } + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::Bytes(_) + | Expr::String(_) + | Expr::ModuleCall(..) + | Expr::ModuleFunctionRef(..) + | Expr::Var(_) + | Expr::MoveVar(_) + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } => {} + Expr::OptionalGet { + container, + key, + container_slot: _, + key_slot: _, + } => { + resolve_expr_imported_calls(ctx, container, line)?; + resolve_expr_imported_calls(ctx, key, line)?; + } + Expr::OptionUnwrapOr { + value, + value_slot: _, + fallback, + } => { + resolve_expr_imported_calls(ctx, value, line)?; + resolve_expr_imported_calls(ctx, fallback, line)?; + } + Expr::LocalCall(_, _, args) => { + for arg in args.iter_mut() { + resolve_expr_imported_calls(ctx, arg, line)?; + } + } + Expr::Closure(closure) => { + resolve_expr_imported_calls(ctx, &mut closure.body, line)?; + } + Expr::ClosureCall(closure, args) => { + resolve_expr_imported_calls(ctx, &mut closure.body, line)?; + for arg in args.iter_mut() { + resolve_expr_imported_calls(ctx, arg, line)?; + } + } + Expr::Add(lhs, rhs) + | Expr::Sub(lhs, rhs) + | Expr::Mul(lhs, rhs) + | Expr::Div(lhs, rhs) + | Expr::Mod(lhs, rhs) + | Expr::And(lhs, rhs) + | Expr::Or(lhs, rhs) + | Expr::Eq(lhs, rhs) + | Expr::Lt(lhs, rhs) + | Expr::Gt(lhs, rhs) => { + resolve_expr_imported_calls(ctx, lhs, line)?; + resolve_expr_imported_calls(ctx, rhs, line)?; + } + Expr::Neg(inner) + | Expr::Not(inner) + | Expr::ToOwned(inner) + | Expr::Borrow(inner) + | Expr::BorrowMut(inner) => { + resolve_expr_imported_calls(ctx, inner, line)?; + } + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + resolve_expr_imported_calls(ctx, condition, line)?; + resolve_expr_imported_calls(ctx, then_expr, line)?; + resolve_expr_imported_calls(ctx, else_expr, line)?; + } + Expr::Match { + value_slot: _, + result_slot: _, + value, + arms, + default, + } => { + resolve_expr_imported_calls(ctx, value, line)?; + for (_, arm_expr) in arms.iter_mut() { + resolve_expr_imported_calls(ctx, arm_expr, line)?; + } + resolve_expr_imported_calls(ctx, default, line)?; + } + Expr::Block { stmts, expr } => { + for stmt in stmts.iter_mut() { + resolve_stmt_imported_calls(ctx, stmt)?; + } + resolve_expr_imported_calls(ctx, expr, line)?; + } + } + Ok(()) +} + +/// Source line of one statement, used to attribute unresolved/ambiguous +/// imported-call diagnostics to the owning module source. +fn stmt_line(stmt: &Stmt) -> u32 { + match stmt { + Stmt::Noop { line } + | Stmt::Break { line } + | Stmt::Continue { line } + | Stmt::Drop { line, .. } + | Stmt::ClosureLet { line, .. } + | Stmt::FuncDecl { line, .. } + | Stmt::Let { line, .. } + | Stmt::Assign { line, .. } + | Stmt::Expr { line, .. } + | Stmt::IfElse { line, .. } + | Stmt::For { line, .. } + | Stmt::While { line, .. } => *line, + } +} diff --git a/src/compiler/source_loader/imports.rs b/src/compiler/source_loader/imports.rs index 41281466..b8f8615c 100644 --- a/src/compiler/source_loader/imports.rs +++ b/src/compiler/source_loader/imports.rs @@ -1,10 +1,14 @@ -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use crate::builtins::is_builtin_namespace; use super::super::frontends::{is_ident_continue, is_ident_start}; -use super::super::{CompileSourceFileOptions, SourceFlavor, SourcePathError}; -use super::model::{ImportClause, ModuleImport, NamedImport}; +use super::super::modules::{UseDecl, use_path_to_spec}; +use super::super::{ + CompileSourceFileOptions, SharedParserOptions, SourceError, SourceFlavor, SourcePathError, + frontends, +}; +use super::model::ModuleImport; pub(super) fn parse_module_imports( source: &str, @@ -12,235 +16,94 @@ pub(super) fn parse_module_imports( path: &Path, options: &CompileSourceFileOptions, ) -> Result, SourcePathError> { - match flavor { - SourceFlavor::RustScript => parse_rustscript_imports(source, path), - SourceFlavor::JavaScript | SourceFlavor::Lua => options - .source_plugin_for_flavor(flavor) - .ok_or(SourcePathError::MissingFrontendPlugin(flavor))? - .parse_module_imports(source, path), - } + scan_module_imports(source, flavor, path, options).map(|(imports, _)| imports) } -fn parse_rustscript_imports( +/// Scan the module imports of one source. +/// +/// For RustScript this parses the source once with the real frontend parser +/// and consumes the structured `use` declaration nodes, so import discovery +/// shares the parser's spans, clauses, and syntax validation instead of +/// treating line-prefix stripping as the authoritative import parser. The +/// paired [`UseDecl`] list is returned alongside the legacy `ModuleImport` +/// list so graph construction can preserve spans. Other flavors keep their +/// plugin-based discovery and contribute no structured declarations. +pub(super) fn scan_module_imports( source: &str, + flavor: SourceFlavor, path: &Path, -) -> Result, SourcePathError> { - let mut imports = Vec::new(); - for (idx, raw_line) in source.lines().enumerate() { - let line_no = idx + 1; - let line = raw_line.trim(); - if line.starts_with("import ") { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line: line_no, - message: "RustScript uses 'use', not 'import'".to_string(), - }); + options: &CompileSourceFileOptions, +) -> Result<(Vec, Vec), SourcePathError> { + match flavor { + SourceFlavor::RustScript => { + let decls = parse_rustscript_use_declarations(source, path)?; + let imports = use_declarations_to_module_imports(path, &decls)?; + Ok((imports, decls)) } - if !line.starts_with("use ") { - continue; + SourceFlavor::JavaScript | SourceFlavor::Lua => { + let imports = options + .source_plugin_for_flavor(flavor) + .ok_or(SourcePathError::MissingFrontendPlugin(flavor))? + .parse_module_imports(source, path)?; + Ok((imports, Vec::new())) } - let tail = line["use ".len()..].trim(); - let (spec, clause) = parse_rustscript_use(path, line_no, tail)?; - imports.push(ModuleImport { - spec, - clause, - line: line_no, - }); } - Ok(imports) } -fn parse_rustscript_use( +/// Parse all `use` directives of a RustScript source into structured nodes. +/// +/// The whole source is parsed with the real frontend parser (with implicit +/// externs enabled and file-path host aliases recorded) so that discovery +/// tolerates calls to functions imported from other modules, which the +/// loader's semantic resolution pass resolves later. +fn parse_rustscript_use_declarations( + source: &str, path: &Path, - line: usize, - tail: &str, -) -> Result<(String, ImportClause), SourcePathError> { - let Some((directive_body, _)) = tail.split_once(';') else { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected ';' at end of use directive".to_string(), - }); - }; - let directive_body = directive_body.trim(); - if directive_body.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected module path after 'use'".to_string(), - }); - } - - if let Some(module_path) = directive_body.strip_suffix("::*") { - let spec = rustscript_use_module_to_spec(path, line, module_path.trim())?; - return Ok((spec, ImportClause::AllPublic)); - } - - if let Some(open_idx) = directive_body.find("::{") { - if !directive_body.ends_with('}') { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected '}' to close use list".to_string(), - }); - } - let module_path = directive_body[..open_idx].trim(); - let spec = rustscript_use_module_to_spec(path, line, module_path)?; - let inner = directive_body[open_idx + 3..directive_body.len() - 1].trim(); - if inner == "*" { - return Ok((spec, ImportClause::AllPublic)); - } - if inner.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "use list requires at least one symbol".to_string(), - }); - } - let named = - parse_named_imports(inner).ok_or_else(|| SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: - "invalid use list; expected comma-separated names with optional 'as' aliases" - .to_string(), - })?; - if named.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "use list requires at least one symbol".to_string(), - }); - } - return Ok((spec, ImportClause::Named(named))); - } - - if let Some((module_path, alias)) = directive_body.rsplit_once(" as ") { - let spec = rustscript_use_module_to_spec(path, line, module_path.trim())?; - let alias = alias.trim(); - if !is_valid_ident(alias) { +) -> Result, SourcePathError> { + for (idx, raw_line) in source.lines().enumerate() { + let line = raw_line.trim(); + if line.starts_with("import ") { return Err(SourcePathError::InvalidImportSyntax { path: path.to_path_buf(), - line, - message: "invalid namespace alias in use directive".to_string(), + line: idx + 1, + message: "RustScript uses 'use', not 'import'".to_string(), }); } - return Ok((spec, ImportClause::Namespace(alias.to_string()))); } - let spec = rustscript_use_module_to_spec(path, line, directive_body)?; - Ok((spec, ImportClause::AllPublic)) + let options = CompileSourceFileOptions::default(); + let dialect = frontends::parser_dialect_for_flavor(SourceFlavor::RustScript, &options) + .expect("RustScript parser dialect is always registered"); + let ir = frontends::parse_source_with_dialect( + source, + dialect, + SharedParserOptions { + source_id: 0, + allow_implicit_externs: true, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: true, + }, + ) + .map_err(|err| SourcePathError::Source(SourceError::Parse(err)))?; + Ok(ir.use_declarations) } -fn rustscript_use_module_to_spec( +fn use_declarations_to_module_imports( path: &Path, - line: usize, - module_path: &str, -) -> Result { - if module_path.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected module path after 'use'".to_string(), - }); - } - let segments = module_path - .split("::") - .map(|segment| segment.trim()) - .collect::>(); - if segments.iter().any(|segment| segment.is_empty()) { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "invalid module path in use directive".to_string(), - }); - } - - let mut path_prefix = PathBuf::new(); - let mut cursor = 0usize; - while cursor < segments.len() { - match segments[cursor] { - "self" => cursor += 1, - "super" => { - path_prefix.push(".."); - cursor += 1; - } - "crate" => { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "crate:: paths are not supported; use relative module paths" - .to_string(), - }); - } - _ => break, - } - } - - if cursor >= segments.len() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected module name after path qualifiers".to_string(), - }); - } - - for segment in &segments[cursor..] { - if !is_valid_ident(segment) { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: format!("invalid module path segment '{segment}' in use directive"), - }); - } - path_prefix.push(segment); - } - - let mut spec = path_prefix.to_string_lossy().replace('\\', "/"); - if spec.is_empty() { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line, - message: "expected module path after 'use'".to_string(), - }); - } - if !spec.ends_with(".rss") { - spec.push_str(".rss"); - } - Ok(spec) -} - -fn parse_named_imports(input: &str) -> Option> { - let mut named = Vec::new(); - for part in input.split(',') { - let entry = part.trim(); - if entry.is_empty() { - continue; - } - - if let Some((imported, local)) = entry.split_once(" as ") { - let imported = imported.trim(); - let local = local.trim(); - if !is_valid_ident(imported) || !is_valid_ident(local) { - return None; - } - named.push(NamedImport { - imported: imported.to_string(), - local: local.to_string(), - }); - continue; - } - - if !is_valid_ident(entry) { - return None; - } - named.push(NamedImport { - imported: entry.to_string(), - local: entry.to_string(), - }); - } - - Some(named) + decls: &[UseDecl], +) -> Result, SourcePathError> { + decls + .iter() + .map(|decl| { + let spec = use_path_to_spec(path, decl.line, &decl.path)?; + Ok(ModuleImport { + spec, + clause: decl.clause.clone(), + line: decl.line, + }) + }) + .collect() } pub(super) fn is_valid_ident(input: &str) -> bool { @@ -278,7 +141,7 @@ pub(super) fn resolve_module_path( if path.extension().and_then(|value| value.to_str()) != Some("rss") { return Err(SourcePathError::NonRustScriptModule(path)); } - return Ok(path); + return Ok(module_identity(path)); } if options.module_override_source(spec).is_some() { let parent = base_path @@ -295,7 +158,7 @@ pub(super) fn resolve_module_path( if path.extension().and_then(|value| value.to_str()) != Some("rss") { return Err(SourcePathError::NonRustScriptModule(path)); } - return Ok(path); + return Ok(module_identity(path)); } let parent = base_path @@ -312,71 +175,67 @@ pub(super) fn resolve_module_path( if path.extension().and_then(|value| value.to_str()) != Some("rss") { return Err(SourcePathError::NonRustScriptModule(path)); } - Ok(path) + Ok(module_identity(path)) +} + +/// Resolve the module identity for a normalized path. +/// +/// Files that exist on disk use their canonical path so that lexically +/// distinct but equivalent paths (`.`, `..`, symlinks) collapse to one +/// module identity for `seen`/`visiting`/exports/overrides. Paths that do not +/// exist on disk (virtual source overrides, in-memory entry points) keep the +/// normalized lexical path as their explicit virtual identity. +pub(super) fn module_identity(path: PathBuf) -> PathBuf { + let normalized = normalize_module_path(path); + if normalized.is_file() + && let Ok(canonical) = normalized.canonicalize() + { + return canonical; + } + normalized +} + +fn normalize_module_path(path: PathBuf) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => match normalized.components().next_back() { + Some(Component::Normal(_)) => { + normalized.pop(); + } + Some(Component::ParentDir) | None => normalized.push(component.as_os_str()), + Some(Component::RootDir | Component::Prefix(_)) => {} + Some(Component::CurDir) => { + unreachable!("normalized paths omit current-dir components") + } + }, + Component::RootDir | Component::Prefix(_) | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + } + } + normalized } +/// Prepare source text for the compile parse. +/// +/// RustScript no longer strips `use` directives: the parser consumes every +/// directive into a structured node (host-namespace forms keep their existing +/// dedicated handling), so line-prefix stripping is no longer an authority +/// for import discovery. Other flavors keep plugin-defined stripping. pub(super) fn strip_import_directives( source: &str, flavor: SourceFlavor, options: &CompileSourceFileOptions, ) -> Result { - let stripped = match flavor { - SourceFlavor::RustScript => source - .lines() - .map(|line| { - if line.trim_start().starts_with("use ") - && !is_direct_host_namespace_use_directive_line(line.trim_start()) - && !is_builtin_namespace_use_directive_line(line.trim_start()) - { - String::new() - } else { - line.to_string() - } - }) - .collect::>() - .join("\n"), - SourceFlavor::JavaScript | SourceFlavor::Lua => options + match flavor { + SourceFlavor::RustScript => Ok(source.to_string()), + SourceFlavor::JavaScript | SourceFlavor::Lua => Ok(options .source_plugin_for_flavor(flavor) .ok_or(SourcePathError::MissingFrontendPlugin(flavor))? - .strip_import_directives(source), - }; - Ok(stripped) -} - -fn is_direct_host_namespace_use_directive_line(line: &str) -> bool { - let trimmed = line.trim(); - if !trimmed.starts_with("use ") { - return false; - } - let Some((directive_body, _)) = trimmed["use ".len()..].split_once(';') else { - return false; - }; - let directive_body = directive_body.trim(); - if directive_body.contains("::{") || directive_body.ends_with("::*") { - return false; + .strip_import_directives(source)), } - if let Some((namespace, alias)) = directive_body.split_once(" as ") { - return is_virtual_host_namespace_spec( - namespace.trim(), - &CompileSourceFileOptions::default(), - ) && is_valid_ident(alias.trim()); - } - is_virtual_host_namespace_spec(directive_body, &CompileSourceFileOptions::default()) -} - -fn is_builtin_namespace_use_directive_line(line: &str) -> bool { - let trimmed = line.trim(); - if !trimmed.starts_with("use ") { - return false; - } - let Some((directive_body, _)) = trimmed["use ".len()..].split_once(';') else { - return false; - }; - let directive_body = directive_body.trim(); - if let Some((namespace, _alias)) = directive_body.split_once(" as ") { - return is_builtin_namespace(namespace.trim()); - } - is_builtin_namespace(directive_body) } pub(super) fn host_namespace_root_from_spec(spec: &str) -> Option { @@ -415,3 +274,145 @@ pub(super) fn should_treat_missing_module_as_host_namespace( std::io::ErrorKind::NotFound | std::io::ErrorKind::Unsupported ) && is_virtual_host_namespace_spec(spec, options) } + +#[cfg(test)] +mod tests { + use super::super::super::modules::UsePathSegment; + use super::super::SourceFlavor; + use super::super::model::ImportClause; + use super::{ + module_identity, normalize_module_path, parse_module_imports, scan_module_imports, + }; + use std::path::PathBuf; + + #[test] + fn normalize_module_path_preserves_unmatched_parent_components() { + assert_eq!( + normalize_module_path(PathBuf::from("../foo/../../bar")), + PathBuf::from("../../bar") + ); + assert_eq!( + normalize_module_path(PathBuf::from("foo/../../../bar")), + PathBuf::from("../../bar") + ); + } + + #[cfg(unix)] + #[test] + fn normalize_module_path_does_not_escape_absolute_root() { + assert_eq!( + normalize_module_path(PathBuf::from("/foo/../../bar")), + PathBuf::from("/bar") + ); + } + + #[test] + fn module_identity_keeps_normalized_virtual_path_for_missing_files() { + assert_eq!( + module_identity(PathBuf::from("/no/such/dir/../virtual/nested.rss")), + PathBuf::from("/no/such/virtual/nested.rss") + ); + } + + #[test] + fn module_identity_uses_canonical_path_for_existing_files() { + let unique = format!( + "pd-vm-module-identity-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp root should be created"); + let module = root.join("a.rss"); + std::fs::write(&module, "pub fn value() -> int { 1 }\n").expect("module should write"); + + let via_dot = module_identity(root.join("./a.rss")); + let via_parent = module_identity(root.join("sub/../a.rss")); + let canonical = module.canonicalize().expect("module should canonicalize"); + + assert_eq!(via_dot, canonical); + assert_eq!(via_parent, canonical); + assert_eq!(via_dot, via_parent); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn structured_scan_preserves_spans_clauses_and_lines() { + let source = "use self::nested as nested;\nuse sibling::{value as v, other};\nuse super::shared;\nuse io;\n"; + let path = PathBuf::from("/root/pkg/main.rss"); + let (imports, decls) = + scan_module_imports(source, SourceFlavor::RustScript, &path, &Default::default()) + .expect("scan should succeed"); + + assert_eq!(imports.len(), 4); + assert_eq!(imports[0].spec, "./nested.rss"); + assert_eq!(imports[1].spec, "sibling.rss"); + assert_eq!(imports[2].spec, "../shared.rss"); + assert_eq!(imports[3].spec, "io.rss"); + + assert_eq!(decls.len(), 4); + assert_eq!( + decls[0].path, + vec![ + UsePathSegment::Self_, + UsePathSegment::Ident("nested".to_string()) + ] + ); + assert!(matches!(&decls[0].clause, ImportClause::Namespace(alias) if alias == "nested")); + assert_eq!(decls[0].line, 1); + assert_eq!(decls[1].line, 2); + assert!( + decls[0].span.lo < decls[0].span.hi, + "span must cover the directive" + ); + assert!(matches!(&decls[1].clause, ImportClause::Named(named) if named.len() == 2)); + assert!(matches!(&decls[3].clause, ImportClause::AllPublic)); + } + + #[test] + fn structured_scan_handles_wildcard_and_alias_forms() { + let source = "use a::b::*;\nuse c::d::{x};\nuse e as f;\n"; + let path = PathBuf::from("/root/main.rss"); + let (imports, decls) = + scan_module_imports(source, SourceFlavor::RustScript, &path, &Default::default()) + .expect("scan should succeed"); + + assert_eq!(imports[0].spec, "a/b.rss"); + assert!(matches!(imports[0].clause, ImportClause::AllPublic)); + assert_eq!(imports[1].spec, "c/d.rss"); + assert!(matches!(&imports[1].clause, ImportClause::Named(named) if named.len() == 1)); + assert_eq!(imports[2].spec, "e.rss"); + assert!(matches!(&imports[2].clause, ImportClause::Namespace(alias) if alias == "f")); + assert_eq!(decls[1].path.len(), 2); + } + + #[test] + fn structured_scan_rejects_import_keyword() { + let source = "import \"./module.rss\";\n"; + let path = PathBuf::from("/root/main.rss"); + let err = + parse_module_imports(source, SourceFlavor::RustScript, &path, &Default::default()) + .expect_err("import keyword should be rejected"); + assert!( + err.to_string().contains("uses 'use', not 'import'"), + "unexpected error: {err}" + ); + } + + #[test] + fn structured_scan_rejects_crate_paths() { + let source = "use crate::x;\n"; + let path = PathBuf::from("/root/main.rss"); + let err = + parse_module_imports(source, SourceFlavor::RustScript, &path, &Default::default()) + .expect_err("crate:: paths should be rejected"); + assert!( + err.to_string().contains("crate:: paths are not supported"), + "unexpected error: {err}" + ); + } +} diff --git a/src/compiler/source_loader/line_map.rs b/src/compiler/source_loader/line_map.rs deleted file mode 100644 index 34853d48..00000000 --- a/src/compiler/source_loader/line_map.rs +++ /dev/null @@ -1,165 +0,0 @@ -use super::super::ir::{Expr, FrontendIr, Stmt}; - -pub(super) fn remap_frontend_ir_line_numbers(ir: &mut FrontendIr, prelude_lines: usize) { - let offset = u32::try_from(prelude_lines).unwrap_or(u32::MAX); - for stmt in &mut ir.stmts { - remap_stmt_line_numbers(stmt, offset); - } - for function in ir.function_impls.values_mut() { - for stmt in &mut function.body_stmts { - remap_stmt_line_numbers(stmt, offset); - } - remap_expr_line_numbers(&mut function.body_expr, offset); - } -} - -fn remap_line(line: &mut u32, offset: u32) { - *line = (*line).saturating_sub(offset).max(1); -} - -fn remap_stmt_line_numbers(stmt: &mut Stmt, offset: u32) { - match stmt { - Stmt::Noop { line } - | Stmt::Break { line } - | Stmt::Continue { line } - | Stmt::Drop { line, .. } - | Stmt::ClosureLet { line, .. } - | Stmt::FuncDecl { line, .. } => remap_line(line, offset), - Stmt::Let { expr, line, .. } - | Stmt::Assign { expr, line, .. } - | Stmt::Expr { expr, line } => { - remap_line(line, offset); - remap_expr_line_numbers(expr, offset); - } - Stmt::IfElse { - condition, - then_branch, - else_branch, - line, - } => { - remap_line(line, offset); - remap_expr_line_numbers(condition, offset); - for stmt in then_branch { - remap_stmt_line_numbers(stmt, offset); - } - for stmt in else_branch { - remap_stmt_line_numbers(stmt, offset); - } - } - Stmt::For { - init, - condition, - post, - body, - line, - } => { - remap_line(line, offset); - remap_stmt_line_numbers(init, offset); - remap_expr_line_numbers(condition, offset); - remap_stmt_line_numbers(post, offset); - for stmt in body { - remap_stmt_line_numbers(stmt, offset); - } - } - Stmt::While { - condition, - body, - line, - } => { - remap_line(line, offset); - remap_expr_line_numbers(condition, offset); - for stmt in body { - remap_stmt_line_numbers(stmt, offset); - } - } - } -} - -fn remap_expr_line_numbers(expr: &mut Expr, offset: u32) { - match expr { - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { - for arg in args { - remap_expr_line_numbers(arg, offset); - } - } - Expr::ClosureCall(closure, args) => { - remap_closure_line_numbers(closure, offset); - for arg in args { - remap_expr_line_numbers(arg, offset); - } - } - Expr::Closure(closure) => remap_closure_line_numbers(closure, offset), - Expr::OptionalGet { container, key, .. } => { - remap_expr_line_numbers(container, offset); - remap_expr_line_numbers(key, offset); - } - Expr::OptionUnwrapOr { - value, fallback, .. - } => { - remap_expr_line_numbers(value, offset); - remap_expr_line_numbers(fallback, offset); - } - Expr::Add(lhs, rhs) - | Expr::Sub(lhs, rhs) - | Expr::Mul(lhs, rhs) - | Expr::Div(lhs, rhs) - | Expr::Mod(lhs, rhs) - | Expr::And(lhs, rhs) - | Expr::Or(lhs, rhs) - | Expr::Eq(lhs, rhs) - | Expr::Lt(lhs, rhs) - | Expr::Gt(lhs, rhs) => { - remap_expr_line_numbers(lhs, offset); - remap_expr_line_numbers(rhs, offset); - } - Expr::Neg(inner) - | Expr::Not(inner) - | Expr::ToOwned(inner) - | Expr::Borrow(inner) - | Expr::BorrowMut(inner) => { - remap_expr_line_numbers(inner, offset); - } - Expr::IfElse { - condition, - then_expr, - else_expr, - } => { - remap_expr_line_numbers(condition, offset); - remap_expr_line_numbers(then_expr, offset); - remap_expr_line_numbers(else_expr, offset); - } - Expr::Match { - value, - arms, - default, - .. - } => { - remap_expr_line_numbers(value, offset); - for (_, arm_expr) in arms { - remap_expr_line_numbers(arm_expr, offset); - } - remap_expr_line_numbers(default, offset); - } - Expr::Block { stmts, expr } => { - for stmt in stmts { - remap_stmt_line_numbers(stmt, offset); - } - remap_expr_line_numbers(expr, offset); - } - Expr::Null - | Expr::Int(_) - | Expr::Float(_) - | Expr::Bool(_) - | Expr::Bytes(_) - | Expr::String(_) - | Expr::FunctionRef(..) - | Expr::Var(_) - | Expr::MoveVar(_) - | Expr::MoveField { .. } - | Expr::MoveIndex { .. } => {} - } -} - -fn remap_closure_line_numbers(closure: &mut crate::compiler::ir::ClosureExpr, offset: u32) { - remap_expr_line_numbers(&mut closure.body, offset); -} diff --git a/src/compiler/source_loader/model.rs b/src/compiler/source_loader/model.rs index 172ff65f..4199de19 100644 --- a/src/compiler/source_loader/model.rs +++ b/src/compiler/source_loader/model.rs @@ -2,7 +2,8 @@ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use super::super::linker::ParsedUnit; - +use super::super::modules::ModuleGraph; +use super::super::source_map::SourceMap; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FrontendImportSyntax { RustScript, @@ -43,7 +44,10 @@ pub(super) struct ModuleCollectState { pub(super) seen: HashSet, pub(super) units: Vec, pub(super) module_exports: HashMap>, -} -pub(super) struct ImportRewriteResult { - pub(super) source: String, + pub(super) module_graph: ModuleGraph, + /// Compilation-wide source map keyed by the module graph's + /// [`SourceId`](super::super::modules::SourceId) space. Every module's + /// raw text is registered here at its graph source id so spans produced + /// during load and merge resolve to the owning source for diagnostics. + pub(super) sources: SourceMap, } diff --git a/src/compiler/source_loader/rewrite.rs b/src/compiler/source_loader/rewrite.rs deleted file mode 100644 index 4dd305b9..00000000 --- a/src/compiler/source_loader/rewrite.rs +++ /dev/null @@ -1,780 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -use super::super::frontends::{is_ident_continue, is_ident_start}; -use super::super::{CompileSourceFileOptions, SourceFlavor, SourcePathError}; - -use super::imports::{ - host_namespace_root_from_spec, is_builtin_host_namespace_spec, is_module_specifier, - is_valid_ident, is_virtual_host_namespace_spec, resolve_module_path, -}; -use super::model::{ExportedFunctionSignature, ImportClause, ImportRewriteResult, ModuleImport}; - -struct ImportCallResolution { - alias_calls: HashMap, - namespace_calls: HashMap>, - namespace_prefix_calls: HashMap, -} - -fn resolve_import_call_paths( - flavor: SourceFlavor, - path: &Path, - imports: &[ModuleImport], - module_exports: &HashMap>, - options: &CompileSourceFileOptions, -) -> Result { - let mut alias_calls = HashMap::::new(); - let mut namespace_calls = HashMap::>::new(); - let namespace_prefix_calls = HashMap::::new(); - for import in imports { - if is_builtin_host_namespace_spec(&import.spec) { - continue; - } - if !is_module_specifier(&import.spec) { - if let Some(host_root) = host_namespace_root_from_spec(&import.spec) - && is_virtual_host_namespace_spec(&import.spec, options) - && let Some(host_prefix) = virtual_host_namespace_prefix(flavor, &host_root) - { - match &import.clause { - ImportClause::AllPublic => {} - ImportClause::Named(named) => { - for binding in named { - alias_calls.insert( - binding.local.clone(), - format!("{host_prefix}::{}", binding.imported), - ); - } - } - ImportClause::Namespace(_namespace) => {} - ImportClause::Prefix(_) => {} - } - } - continue; - } - - let resolved = resolve_module_path(path, &import.spec, options)?; - let Some(exports) = module_exports.get(&resolved) else { - if let Some(host_root) = host_namespace_root_from_spec(&import.spec) - && is_virtual_host_namespace_spec(&import.spec, options) - && let Some(host_prefix) = virtual_host_namespace_prefix(flavor, &host_root) - { - match &import.clause { - ImportClause::AllPublic => {} - ImportClause::Named(named) => { - for binding in named { - alias_calls.insert( - binding.local.clone(), - format!("{host_prefix}::{}", binding.imported), - ); - } - } - ImportClause::Namespace(_namespace) => {} - ImportClause::Prefix(_) => {} - } - } - continue; - }; - - match &import.clause { - ImportClause::AllPublic => { - // Bare `use module;` keeps direct calls (`fn_name(...)`) and now also - // supports namespace-style calls (`module::fn_name(...)`) for ergonomics. - if let Some(namespace) = module_default_namespace(&import.spec) { - let entries = namespace_calls.entry(namespace).or_default(); - for name in exports.keys() { - entries.insert(name.clone()); - } - } - } - ImportClause::Named(named) => { - for binding in named { - if !exports.contains_key(&binding.imported) { - return Err(SourcePathError::InvalidImportSyntax { - path: path.to_path_buf(), - line: import.line, - message: format!( - "module '{}' has no public function '{}'", - import.spec, binding.imported - ), - }); - } - if binding.local != binding.imported { - alias_calls.insert(binding.local.clone(), binding.imported.clone()); - } - } - } - ImportClause::Namespace(namespace) => { - let entries = namespace_calls.entry(namespace.clone()).or_default(); - for name in exports.keys() { - entries.insert(name.clone()); - } - } - ImportClause::Prefix(prefix) => { - for name in exports.keys() { - alias_calls.insert(format!("{prefix}{name}"), name.clone()); - } - } - } - } - - Ok(ImportCallResolution { - alias_calls, - namespace_calls, - namespace_prefix_calls, - }) -} - -fn virtual_host_namespace_prefix(flavor: SourceFlavor, host_root: &str) -> Option { - match flavor { - SourceFlavor::RustScript => Some(host_root.to_string()), - SourceFlavor::JavaScript | SourceFlavor::Lua => None, - } -} - -pub(super) fn rewrite_imported_call_sites( - source: &str, - flavor: SourceFlavor, - path: &Path, - imports: &[ModuleImport], - module_exports: &HashMap>, - options: &CompileSourceFileOptions, -) -> Result { - let resolution = resolve_import_call_paths(flavor, path, imports, module_exports, options)?; - let alias_calls = resolution.alias_calls; - let namespace_calls = resolution.namespace_calls; - let namespace_prefix_calls = resolution.namespace_prefix_calls; - let namespace_wildcards = HashSet::::new(); - let prefix_aliases = Vec::::new(); - - let rewritten = rewrite_host_namespace_call_paths(source, flavor, &namespace_prefix_calls); - - if alias_calls.is_empty() - && namespace_calls.is_empty() - && namespace_wildcards.is_empty() - && prefix_aliases.is_empty() - { - return Ok(ImportRewriteResult { source: rewritten }); - } - - Ok(ImportRewriteResult { - source: rewrite_function_call_paths( - &rewritten, - flavor, - &alias_calls, - &namespace_calls, - &namespace_wildcards, - &prefix_aliases, - ), - }) -} - -fn rewrite_host_namespace_call_paths( - source: &str, - flavor: SourceFlavor, - namespace_prefix_calls: &HashMap, -) -> String { - if namespace_prefix_calls.is_empty() { - return source.to_string(); - } - - let bytes = source.as_bytes(); - let mut out = String::with_capacity(source.len()); - let mut i = 0usize; - let mut in_line_comment = false; - let mut in_block_comment = false; - let mut string_delim: Option = None; - let mut escaped = false; - - while i < bytes.len() { - let b = bytes[i]; - - if let Some(delim) = string_delim { - out.push(b as char); - if escaped { - escaped = false; - } else if b == b'\\' { - escaped = true; - } else if b == delim { - string_delim = None; - } - i += 1; - continue; - } - - if in_line_comment { - out.push(b as char); - if b == b'\n' { - in_line_comment = false; - } - i += 1; - continue; - } - - if in_block_comment { - out.push(b as char); - if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { - out.push('/'); - i += 2; - in_block_comment = false; - continue; - } - i += 1; - continue; - } - - if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { - out.push('/'); - out.push('/'); - i += 2; - in_line_comment = true; - continue; - } - - if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' { - out.push('/'); - out.push('*'); - i += 2; - in_block_comment = true; - continue; - } - - if b == b'"' || b == b'\'' || b == b'`' { - out.push(b as char); - i += 1; - string_delim = Some(b); - escaped = false; - continue; - } - - if !is_ident_start(b as char) { - out.push(b as char); - i += 1; - continue; - } - - let start = i; - i += 1; - while i < bytes.len() && is_ident_continue(bytes[i] as char) { - i += 1; - } - let ident = &source[start..i]; - - if let Some(prefix) = namespace_prefix_calls.get(ident) - && namespace_call_target_is_function(source, i, flavor) - { - out.push_str(prefix); - continue; - } - - out.push_str(ident); - } - - out -} - -fn namespace_call_target_is_function(source: &str, index: usize, flavor: SourceFlavor) -> bool { - let bytes = source.as_bytes(); - let mut cursor = index; - if !consume_namespace_separator(bytes, &mut cursor, flavor) { - return false; - } - - loop { - while cursor < bytes.len() - && bytes[cursor].is_ascii_whitespace() - && bytes[cursor] != b'\n' - && bytes[cursor] != b'\r' - { - cursor += 1; - } - if cursor >= bytes.len() || !is_ident_start(bytes[cursor] as char) { - return false; - } - cursor += 1; - while cursor < bytes.len() && is_ident_continue(bytes[cursor] as char) { - cursor += 1; - } - - while cursor < bytes.len() - && bytes[cursor].is_ascii_whitespace() - && bytes[cursor] != b'\n' - && bytes[cursor] != b'\r' - { - cursor += 1; - } - if cursor < bytes.len() && bytes[cursor] == b'(' { - return true; - } - if !consume_namespace_separator(bytes, &mut cursor, flavor) { - return false; - } - } -} - -fn consume_namespace_separator(bytes: &[u8], cursor: &mut usize, flavor: SourceFlavor) -> bool { - if *cursor >= bytes.len() { - return false; - } - if flavor == SourceFlavor::RustScript { - if bytes[*cursor] != b':' { - return false; - } - *cursor += 1; - while *cursor < bytes.len() - && bytes[*cursor].is_ascii_whitespace() - && bytes[*cursor] != b'\n' - && bytes[*cursor] != b'\r' - { - *cursor += 1; - } - if *cursor >= bytes.len() || bytes[*cursor] != b':' { - return false; - } - *cursor += 1; - return true; - } - - if bytes[*cursor] == b'.' { - *cursor += 1; - return true; - } - false -} - -fn module_default_namespace(spec: &str) -> Option { - let stem = Path::new(spec).file_stem()?.to_str()?; - if is_valid_ident(stem) { - Some(stem.to_string()) - } else { - None - } -} - -fn skip_inline_whitespace(bytes: &[u8], mut index: usize) -> usize { - while index < bytes.len() - && bytes[index].is_ascii_whitespace() - && bytes[index] != b'\n' - && bytes[index] != b'\r' - { - index += 1; - } - index -} - -fn rustscript_turbofish_call_starts(bytes: &[u8], start: usize) -> bool { - let mut index = skip_inline_whitespace(bytes, start); - if index >= bytes.len() || bytes[index] != b':' { - return false; - } - index = skip_inline_whitespace(bytes, index + 1); - if index >= bytes.len() || bytes[index] != b':' { - return false; - } - index = skip_inline_whitespace(bytes, index + 1); - if index >= bytes.len() || bytes[index] != b'<' { - return false; - } - - let mut depth = 0usize; - while index < bytes.len() { - match bytes[index] { - b'<' => depth += 1, - b'>' => { - depth = depth.saturating_sub(1); - if depth == 0 { - index += 1; - break; - } - } - b'\n' | b'\r' => return false, - _ => {} - } - index += 1; - } - - if depth != 0 { - return false; - } - - index = skip_inline_whitespace(bytes, index); - index < bytes.len() && bytes[index] == b'(' -} - -fn call_starts_after_position(bytes: &[u8], start: usize, flavor: SourceFlavor) -> bool { - let index = skip_inline_whitespace(bytes, start); - if index < bytes.len() && bytes[index] == b'(' { - return true; - } - flavor == SourceFlavor::RustScript && rustscript_turbofish_call_starts(bytes, start) -} - -fn rewrite_function_call_paths( - source: &str, - flavor: SourceFlavor, - alias_calls: &HashMap, - namespace_calls: &HashMap>, - namespace_wildcards: &HashSet, - prefix_aliases: &[String], -) -> String { - let bytes = source.as_bytes(); - let mut out = String::with_capacity(source.len()); - let mut i = 0usize; - let mut in_line_comment = false; - let mut in_block_comment = false; - let mut string_delim: Option = None; - let mut escaped = false; - - while i < bytes.len() { - let b = bytes[i]; - - if let Some(delim) = string_delim { - out.push(b as char); - if escaped { - escaped = false; - } else if b == b'\\' { - escaped = true; - } else if b == delim { - string_delim = None; - } - i += 1; - continue; - } - - if in_line_comment { - out.push(b as char); - if b == b'\n' { - in_line_comment = false; - } - i += 1; - continue; - } - - if in_block_comment { - out.push(b as char); - if b == b'*' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { - out.push('/'); - i += 2; - in_block_comment = false; - continue; - } - i += 1; - continue; - } - - if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { - out.push('/'); - out.push('/'); - i += 2; - in_line_comment = true; - continue; - } - - if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' { - out.push('/'); - out.push('*'); - i += 2; - in_block_comment = true; - continue; - } - - if b == b'"' || b == b'\'' || b == b'`' { - out.push(b as char); - i += 1; - string_delim = Some(b); - escaped = false; - continue; - } - - if is_ident_start(b as char) { - let start = i; - i += 1; - while i < bytes.len() && is_ident_continue(bytes[i] as char) { - i += 1; - } - let ident = &source[start..i]; - - let namespace_methods = namespace_calls.get(ident); - let namespace_wildcard = namespace_wildcards.contains(ident); - if namespace_methods.is_some() || namespace_wildcard { - let mut j = i; - while j < bytes.len() - && bytes[j].is_ascii_whitespace() - && bytes[j] != b'\n' - && bytes[j] != b'\r' - { - j += 1; - } - - let mut sep_end = None; - if flavor == SourceFlavor::RustScript { - if j < bytes.len() && bytes[j] == b':' { - let mut k = j + 1; - while k < bytes.len() - && bytes[k].is_ascii_whitespace() - && bytes[k] != b'\n' - && bytes[k] != b'\r' - { - k += 1; - } - if k < bytes.len() && bytes[k] == b':' { - sep_end = Some(k + 1); - } - } - } else if j < bytes.len() && bytes[j] == b'.' { - sep_end = Some(j + 1); - } - - if let Some(mut k) = sep_end { - while k < bytes.len() - && bytes[k].is_ascii_whitespace() - && bytes[k] != b'\n' - && bytes[k] != b'\r' - { - k += 1; - } - if k < bytes.len() && is_ident_start(bytes[k] as char) { - let member_start = k; - k += 1; - while k < bytes.len() && is_ident_continue(bytes[k] as char) { - k += 1; - } - let member = &source[member_start..k]; - if call_starts_after_position(bytes, k, flavor) - && (namespace_wildcard - || namespace_methods - .is_some_and(|methods| methods.contains(member))) - { - out.push_str(member); - i = k; - continue; - } - } - } - } - - if let Some(target) = alias_calls.get(ident) - && call_starts_after_position(bytes, i, flavor) - { - out.push_str(target); - continue; - } - - let mut rewritten_by_prefix = false; - for prefix in prefix_aliases { - if !ident.starts_with(prefix) { - continue; - } - let rem = &ident[prefix.len()..]; - if rem.is_empty() || !is_valid_ident(rem) { - continue; - } - if call_starts_after_position(bytes, i, flavor) { - out.push_str(rem); - rewritten_by_prefix = true; - break; - } - } - if rewritten_by_prefix { - continue; - } - - out.push_str(ident); - continue; - } - - out.push(b as char); - i += 1; - } - - out -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - use std::path::{Path, PathBuf}; - - use super::*; - use crate::compiler::CompileSourceFileOptions; - use crate::compiler::source_loader::model::{ - ExportedFunctionSignature, ImportClause, ModuleImport, NamedImport, - }; - - #[test] - fn rustscript_namespace_import_calls_rewrite_to_direct_calls() { - let source = r#" -string::non_empty("rss"); -is_empty(""); -"#; - let path = Path::new("tests/main.rss"); - let imports = vec![ - ModuleImport { - spec: "strings.rss".to_string(), - clause: ImportClause::Namespace("string".to_string()), - line: 1, - }, - ModuleImport { - spec: "strings.rss".to_string(), - clause: ImportClause::Named(vec![NamedImport { - imported: "is_empty".to_string(), - local: "is_empty".to_string(), - }]), - line: 2, - }, - ]; - let mut module_exports = - HashMap::>::new(); - module_exports.insert( - PathBuf::from("tests").join("strings.rss"), - HashMap::from([ - ( - "is_empty".to_string(), - ExportedFunctionSignature { - arity: 1, - type_params: Vec::new(), - }, - ), - ( - "non_empty".to_string(), - ExportedFunctionSignature { - arity: 1, - type_params: Vec::new(), - }, - ), - ]), - ); - - let rewritten = rewrite_imported_call_sites( - source, - SourceFlavor::RustScript, - path, - &imports, - &module_exports, - &CompileSourceFileOptions::default(), - ) - .expect("rewrite should succeed"); - - assert_eq!( - rewritten.source.trim(), - r#" -non_empty("rss"); -is_empty(""); -"# - .trim() - ); - } - - #[test] - fn rustscript_namespace_import_turbofish_calls_rewrite_to_direct_calls() { - let source = r#"collections::dedup::(["rss", "rss"]);"#; - let path = Path::new("tests/main.rss"); - let imports = vec![ModuleImport { - spec: "collections.rss".to_string(), - clause: ImportClause::Namespace("collections".to_string()), - line: 1, - }]; - let mut module_exports = - HashMap::>::new(); - module_exports.insert( - PathBuf::from("tests").join("collections.rss"), - HashMap::from([( - "dedup".to_string(), - ExportedFunctionSignature { - arity: 1, - type_params: vec!["T".to_string()], - }, - )]), - ); - - let rewritten = rewrite_imported_call_sites( - source, - SourceFlavor::RustScript, - path, - &imports, - &module_exports, - &CompileSourceFileOptions::default(), - ) - .expect("rewrite should succeed"); - - assert_eq!( - rewritten.source.trim(), - r#"dedup::(["rss", "rss"]);"# - ); - } - - #[test] - fn rustscript_named_import_turbofish_calls_rewrite_to_direct_calls() { - let source = r#"dedup_items::(["rss", "rss"]);"#; - let path = Path::new("tests/main.rss"); - let imports = vec![ModuleImport { - spec: "collections.rss".to_string(), - clause: ImportClause::Named(vec![NamedImport { - imported: "dedup".to_string(), - local: "dedup_items".to_string(), - }]), - line: 1, - }]; - let mut module_exports = - HashMap::>::new(); - module_exports.insert( - PathBuf::from("tests").join("collections.rss"), - HashMap::from([( - "dedup".to_string(), - ExportedFunctionSignature { - arity: 1, - type_params: vec!["T".to_string()], - }, - )]), - ); - - let rewritten = rewrite_imported_call_sites( - source, - SourceFlavor::RustScript, - path, - &imports, - &module_exports, - &CompileSourceFileOptions::default(), - ) - .expect("rewrite should succeed"); - - assert_eq!( - rewritten.source.trim(), - r#"dedup::(["rss", "rss"]);"# - ); - } - - #[test] - fn rustscript_all_public_import_namespace_calls_rewrite_to_direct_calls() { - let source = "runtime::sleep(3);\n"; - let path = Path::new("tests/main.rss"); - let imports = vec![ModuleImport { - spec: "runtime.rss".to_string(), - clause: ImportClause::AllPublic, - line: 1, - }]; - let mut module_exports = - HashMap::>::new(); - module_exports.insert( - PathBuf::from("tests").join("runtime.rss"), - HashMap::from([( - "sleep".to_string(), - ExportedFunctionSignature { - arity: 1, - type_params: Vec::new(), - }, - )]), - ); - - let rewritten = rewrite_imported_call_sites( - source, - SourceFlavor::RustScript, - path, - &imports, - &module_exports, - &CompileSourceFileOptions::default(), - ) - .expect("rewrite should succeed"); - - assert_eq!(rewritten.source.trim(), "sleep(3);"); - } -} diff --git a/src/compiler/source_map.rs b/src/compiler/source_map.rs index 5839a0c0..622648a8 100644 --- a/src/compiler/source_map.rs +++ b/src/compiler/source_map.rs @@ -118,6 +118,33 @@ impl SourceMap { id } + /// Register a source at an explicit id (the semantic module graph's + /// `SourceId` space) so spans that + /// reference that id resolve to this text. Missing slots are filled with + /// empty placeholders; an already-occupied slot keeps its first text. + pub fn add_source_at( + &mut self, + id: SourceId, + name: impl Into, + text: impl Into, + ) -> SourceId { + let id_usize = id as usize; + while self.files.len() <= id_usize { + let placeholder = self.files.len() as SourceId; + self.files + .push(SourceFile::new(placeholder, String::new(), String::new())); + } + if self.files[id_usize].text.is_empty() && self.files[id_usize].name.is_empty() { + self.files[id_usize] = SourceFile::new(id, name.into(), text.into()); + } + id + } + + /// Display name of the source registered at `id`. + pub fn file_name(&self, id: SourceId) -> Option<&str> { + self.file(id).map(|file| file.name.as_str()) + } + pub fn file(&self, id: SourceId) -> Option<&SourceFile> { self.files.get(id as usize) } diff --git a/src/compiler/typing/collect.rs b/src/compiler/typing/collect.rs index ac048e9a..359051ee 100644 --- a/src/compiler/typing/collect.rs +++ b/src/compiler/typing/collect.rs @@ -519,7 +519,9 @@ fn collect_expr_types( | Expr::MoveVar(_) | Expr::MoveField { .. } | Expr::MoveIndex { .. } - | Expr::FunctionRef(..) => { + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => { let _ = context.infer_expr_type(expr, state); } Expr::OptionalGet { container, key, .. } => { @@ -570,7 +572,7 @@ fn collect_expr_types( ); let _ = context.infer_expr_type(expr, state); } - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => { for arg in args { collect_expr_types( arg, diff --git a/src/compiler/typing/context.rs b/src/compiler/typing/context.rs index 89fc6501..a373ef20 100644 --- a/src/compiler/typing/context.rs +++ b/src/compiler/typing/context.rs @@ -922,8 +922,13 @@ impl<'a> TypeContext<'a> { state.get(*root) } } - Expr::FunctionRef(..) | Expr::Closure(_) => BoundType::Callable, - Expr::Call(..) | Expr::LocalCall(..) => self.infer_call_like_expr_type(expr, state), + Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Closure(_) => BoundType::Callable, + Expr::Call(..) | Expr::ModuleCall(..) | Expr::LocalCall(..) => { + self.infer_call_like_expr_type(expr, state) + } Expr::ClosureCall(_, _) => self.infer_call_like_expr_type(expr, state), Expr::Add(lhs, rhs) | Expr::Sub(lhs, rhs) @@ -1061,7 +1066,14 @@ impl<'a> TypeContext<'a> { .unwrap_or(BoundType::Unknown), }, Expr::ClosureCall(closure, args) => self.infer_closure_return(closure, args, state), - Expr::Closure(_) | Expr::FunctionRef(..) => BoundType::Callable, + Expr::Closure(_) + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } => BoundType::Callable, + // Resolved module calls carry no per-unit type information; the + // prelude declaration they replaced also had an unknown return + // type, so this matches the legacy behavior. + Expr::ModuleCall(..) => BoundType::Unknown, _ => BoundType::Unknown, } } @@ -1975,6 +1987,27 @@ impl<'a> TypeContext<'a> { } return Ok(()); } + // `stream::emit(value)` accepts any single value; the per-item event + // bound is validated at runtime by the invocation stream. The + // exemption is tied to the authoritative runtime builtin identity; a + // same-name function registered through another catalog does not + // inherit it. The identity constant lives in the `runtime`-featured + // builtins module, so in non-runtime builds the comparison is + // compiled out and the exemption does not apply. + #[cfg(feature = "runtime")] + if signature.runtime_builtin + && signature.name == crate::builtins::runtime::context::STREAM_EMIT_NAME + { + return validate_host_signature( + &signature.name, + &signature.params, + args, + state, + self, + line_context, + source_name, + ); + } if self.is_strict() && signature .params @@ -2597,3 +2630,67 @@ fn literal_int_index(key: &Expr) -> Option { }; usize::try_from(*index).ok() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::builtins::{CallableParam, CallableParamType}; + + /// The authoritative `stream::emit` signature: one `any` payload. + fn emit_signature(runtime_builtin: bool) -> HostCallableSignature { + HostCallableSignature { + name: crate::builtins::runtime::context::STREAM_EMIT_NAME.to_string(), + params: vec![CallableParam { + name: "value", + ty: CallableParamType::Any, + optional: false, + }], + runtime_builtin, + } + } + + #[test] + fn stream_emit_any_payload_exemption_requires_authoritative_builtin_identity() { + let empty_impls: HashMap = HashMap::new(); + let empty_decls: HashMap = HashMap::new(); + let empty_structs: HashMap = HashMap::new(); + let empty_names: HashMap = HashMap::new(); + let empty_returns: HashMap = HashMap::new(); + let empty_signatures: HashMap = 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 args = [Expr::Int(1)]; + + assert!( + context + .validate_host_argument_types(&emit_signature(true), &args, &state, None, None,) + .is_ok(), + "the authoritative stream::emit builtin must accept any payload in strict mode" + ); + + // A same-name signature that is not the authoritative runtime builtin + // (for example one registered through another host catalog) must not + // inherit the strict-typing exemption. + assert!( + matches!( + context.validate_host_argument_types( + &emit_signature(false), + &args, + &state, + None, + None, + ), + Err(CompileError::StrictTypingRequired { .. }) + ), + "a same-name non-builtin signature must not inherit the stream::emit exemption" + ); + } +} diff --git a/src/compiler/typing/helpers.rs b/src/compiler/typing/helpers.rs index d8ea130c..4c6d8a31 100644 --- a/src/compiler/typing/helpers.rs +++ b/src/compiler/typing/helpers.rs @@ -1234,6 +1234,7 @@ pub(super) fn known_host_signature(name: &str) -> Option return Some(HostCallableSignature { name: callable.name.to_string(), params: callable.signature.params.to_vec(), + runtime_builtin: true, }); } @@ -1253,6 +1254,7 @@ pub(super) fn known_host_signature(name: &str) -> Option optional: false, }) .collect(), + runtime_builtin: false, }) } @@ -1370,6 +1372,8 @@ pub(super) fn expr_contains_param_add(expr: &Expr, param_slots: &[LocalSlot]) -> | Expr::Bytes(_) | Expr::String(_) | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } | Expr::Var(_) | Expr::MoveVar(_) | Expr::MoveField { .. } @@ -1384,7 +1388,7 @@ pub(super) fn expr_contains_param_add(expr: &Expr, param_slots: &[LocalSlot]) -> expr_contains_param_add(value, param_slots) || expr_contains_param_add(fallback, param_slots) } - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => args + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => args .iter() .any(|arg| expr_contains_param_add(arg, param_slots)), Expr::ClosureCall(closure, args) => { @@ -1445,6 +1449,8 @@ pub(super) fn expr_uses_param(expr: &Expr, param_slots: &[LocalSlot]) -> bool { | Expr::Bytes(_) | Expr::String(_) | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } | Expr::MoveField { .. } | Expr::MoveIndex { .. } => false, Expr::OptionalGet { container, key, .. } => { @@ -1453,7 +1459,7 @@ pub(super) fn expr_uses_param(expr: &Expr, param_slots: &[LocalSlot]) -> bool { Expr::OptionUnwrapOr { value, fallback, .. } => expr_uses_param(value, param_slots) || expr_uses_param(fallback, param_slots), - Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) => { + Expr::Call(_, _, args) | Expr::LocalCall(_, _, args) | Expr::ModuleCall(_, _, args) => { args.iter().any(|arg| expr_uses_param(arg, param_slots)) } Expr::ClosureCall(closure, args) => { @@ -1603,7 +1609,13 @@ pub(super) fn legalize_expr( let _ = legalize_expr(fallback, state, context); context.infer_expr_type(expr, state) } - Expr::FunctionRef(..) | Expr::Call(..) | Expr::LocalCall(..) | Expr::Closure(_) => { + Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Call(..) + | Expr::ModuleCall(..) + | Expr::LocalCall(..) + | Expr::Closure(_) => { legalize_expr_children(expr, state, context); context.infer_call_like_expr_type(expr, state) } @@ -1709,6 +1721,11 @@ pub(super) fn legalize_expr_children( fold_builtin_call(expr, builtin, state); } } + Expr::ModuleCall(_, _, args) => { + for arg in args.iter_mut() { + let _ = legalize_expr(arg, state, context); + } + } Expr::LocalCall(_, _, args) => { for arg in args.iter_mut() { let _ = legalize_expr(arg, state, context); diff --git a/src/compiler/typing/state.rs b/src/compiler/typing/state.rs index 5020bb27..5ce13470 100644 --- a/src/compiler/typing/state.rs +++ b/src/compiler/typing/state.rs @@ -435,4 +435,10 @@ pub(crate) struct TypeInferenceResult { pub(crate) struct HostCallableSignature { pub(crate) name: String, pub(crate) params: Vec, + /// True when this signature came from the authoritative runtime builtin + /// catalog (`default_host_callable`), false when it came from another + /// catalog such as edge ABI host functions. Strict-typing exemptions that + /// are tied to a builtin identity must check this marker so a same-name + /// function from another catalog cannot inherit them. + pub(crate) runtime_builtin: bool, } diff --git a/src/compiler/typing/validate.rs b/src/compiler/typing/validate.rs index fab96ea6..6a2b61fe 100644 --- a/src/compiler/typing/validate.rs +++ b/src/compiler/typing/validate.rs @@ -556,7 +556,13 @@ pub(super) fn validate_expr( )?, Expr::Var(slot) | Expr::MoveVar(slot) => state.get(*slot), Expr::MoveField { root, .. } | Expr::MoveIndex { root, .. } => state.get(*root), - Expr::FunctionRef(..) | Expr::Call(..) | Expr::LocalCall(..) | Expr::Closure(_) => { + Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Call(..) + | Expr::ModuleCall(..) + | Expr::LocalCall(..) + | Expr::Closure(_) => { validate_expr_children( expr, state, diff --git a/src/lib.rs b/src/lib.rs index d28b0a5c..854a5edc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,10 @@ 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 = "runtime")] +pub use builtins::runtime::{HttpConfig, HttpHostExt, 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, @@ -35,21 +39,27 @@ pub use builtins::{ pub use bytecode::{ CallableEnvironment, CallableKind, CallablePrototype, CallableTarget, CallableValue, CaptureBindingMode, ExportedCallable, FunctionRegion, HostImport, OpCode, Program, - RootCallableBinding, ScriptFunction, TypeMap, Value, ValueType, + RootCallableBinding, ScriptFunction, TypeMap, Value, ValueType, VmMap, }; pub fn builtin_call_index(name: &str) -> Option { use builtins::BuiltinFunction; BuiltinFunction::from_source_name(name).map(|builtin| builtin.call_index()) } -pub use compiler::diagnostics::{render_compile_error, render_source_error}; +#[cfg(feature = "runtime")] +pub use builtins::runtime::error::{RuntimeError, RuntimeErrorCode, RuntimeResult}; +pub use compiler::diagnostics::{ + render_compile_error, render_source_error, render_source_path_error, +}; pub use compiler::source_map::{LineSpanMapping, LoweredSource, SourceId, SourceMap, Span}; pub use compiler::{ AssignmentKind, ClosureExpr, CompileError, CompileSourceFileOptions, CompiledProgram, - CompiledReplProgram, Compiler, Expr, FormatError, FrontendImportSyntax, FrontendIr, - FunctionDecl, ImportClause, InferredLocalTypeHint, LocalIrBuilder, LocalSlot, ModuleImport, - NamedImport, ParseError, ParserDialect, ReplLocalBinding, ReplLocalState, SharedParserOptions, - SourceError, SourceFlavor, SourcePathError, SourcePlugin, Stmt, UnknownInferredLocal, + CompiledReplProgram, Compiler, DeclSymbol, ExportEntry, Expr, FormatError, + FrontendImportSyntax, FrontendIr, FunctionDecl, ImportClause, ImportTargetKind, + ImportedBinding, InferredLocalTypeHint, LocalIrBuilder, LocalSlot, ModuleGraph, ModuleId, + ModuleImport, ModuleNode, NamedImport, ParseError, ParserDialect, ReplLocalBinding, + ReplLocalState, ResolvedImport, SharedParserOptions, SourceError, SourceFlavor, + SourcePathError, SourcePlugin, Stmt, SymbolId, UnknownInferredLocal, UseDecl, UsePathSegment, collect_inferred_local_type_hints, collect_inferred_local_type_hints_at_path_with_options, collect_inferred_local_type_hints_with_options, compile_source, compile_source_at_path_with_flavor_and_options, compile_source_file, @@ -78,12 +88,16 @@ pub use jit::{ pub use vm::diagnostics::render_vm_error; #[cfg(feature = "runtime")] pub use vm::{ - AotArtifactError, CallOutcome, CallReturn, DEFAULT_MAX_SCRIPT_CALL_DEPTH, EpochCheckpoint, - EpochHandle, FuelCheckpoint, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, - HostFunctionRegistry, HostOpId, HostStackFunction, IntoScriptValue, QueuedScriptInvocation, - ScriptArgs, ScriptCallback, ScriptResult, StaticHostArgsFunction, StaticHostFunction, - StaticHostStackFunction, Store, Vm, VmError, VmResult, VmStatus, VmYieldReason, + AotArtifactError, CallOutcome, CallReturn, CancellationReason, CapabilityProfile, + 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, }; + #[cfg(feature = "runtime")] pub use vmbc::{ DisassembleOptions, ValidationError, WireError, decode_program, disassemble_program, diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index 37e31f83..8783ad00 100644 --- a/src/vm/aot/artifact.rs +++ b/src/vm/aot/artifact.rs @@ -3,7 +3,8 @@ use std::path::Path; use crate::bytecode::Program; use crate::vm::native::{ - helper_entry_offset, interrupt_helper_entry_offset, selected_codegen_backend, + detect_native_stack_layout, helper_entry_offset, interrupt_helper_entry_offset, + native_stack_layout_fingerprint, selected_codegen_backend, }; use crate::vm::{Vm, VmError}; @@ -11,8 +12,8 @@ use super::super::jit::JitConfig; use super::compile::CompiledProgram; const MAGIC: [u8; 4] = *b"PAT\0"; -const VERSION: u16 = 7; -const ABI_VERSION: u16 = 6; +const VERSION: u16 = 8; +const ABI_VERSION: u16 = 8; const FLAG_INTERPRETER_BOUNDARY_ONLY: u16 = 1; const SUPPORTED_FLAGS: u16 = FLAG_INTERPRETER_BOUNDARY_ONLY; @@ -108,11 +109,12 @@ impl From for AotArtifactError { impl Vm { pub fn encode_aot_artifact(&mut self) -> Result, AotArtifactError> { - if self.aot_program.is_none() { + if self.engine.aot_program.is_none() { self.compile_aot()?; } let program_hash = self.ensure_program_cache_key(); let aot_program = self + .engine .aot_program .as_ref() .ok_or(AotArtifactError::MissingAotProgram)?; @@ -135,8 +137,8 @@ impl Vm { } else { CompiledProgram::from_code(decoded.code, decoded.resume_ips)? }; - self.aot_program = Some(compiled); - self.aot_exec_count = 0; + self.engine.aot_program = Some(compiled); + self.engine.aot_exec_count = 0; Ok(()) } @@ -159,8 +161,8 @@ impl Vm { } else { CompiledProgram::from_code(decoded.code, decoded.resume_ips)? }; - vm.aot_program = Some(compiled); - vm.aot_exec_count = 0; + vm.engine.aot_program = Some(compiled); + vm.engine.aot_exec_count = 0; Ok(vm) } @@ -201,7 +203,8 @@ fn encode_artifact( write_string("os", std::env::consts::OS, &mut out)?; write_string("backend", selected_codegen_backend(), &mut out)?; - write_u32("vm ip offset", std::mem::offset_of!(Vm, ip), &mut out)?; + let native_layout_fingerprint = native_stack_layout_fingerprint(detect_native_stack_layout()?); + out.extend_from_slice(&native_layout_fingerprint.to_le_bytes()); write_u32( "native helper offset", helper_entry_offset() as usize, @@ -281,10 +284,12 @@ fn decode_artifact( selected_codegen_backend().to_string(), cursor.read_string()?, )?; + let expected_layout_fingerprint = + native_stack_layout_fingerprint(detect_native_stack_layout()?); validate_runtime_field( - "vm ip offset", - std::mem::offset_of!(Vm, ip).to_string(), - cursor.read_u32()?.to_string(), + "native stack layout fingerprint", + format!("{expected_layout_fingerprint:#018x}"), + format!("{:#018x}", cursor.read_u64()?), )?; validate_runtime_field( "native helper offset", @@ -438,15 +443,30 @@ impl<'a> Cursor<'a> { #[cfg(test)] mod tests { use super::*; + use crate::vm::native::{detect_native_stack_layout, native_stack_layout_fingerprint}; use crate::{BytecodeBuilder, Program, Value, ValueType, VmStatus}; + fn native_layout_fingerprint_offset(encoded: &[u8]) -> usize { + let mut offset = 11; + for _ in 0..3 { + let len = u32::from_le_bytes( + encoded[offset..offset + 4] + .try_into() + .expect("runtime string length"), + ) as usize; + offset += 4 + len; + } + offset + } + #[test] fn aot_artifact_preserves_interpreter_boundary_mode() { let mut bc = BytecodeBuilder::new(); bc.ret(); let mut vm = Vm::new(Program::new(Vec::new(), bc.finish())); vm.compile_aot().expect("aot compile should succeed"); - vm.aot_program + vm.engine + .aot_program .as_mut() .expect("compiled program") .interpreter_boundary_only = true; @@ -468,6 +488,7 @@ mod tests { .expect("boundary artifact should load"); assert!( standalone + .engine .aot_program .as_ref() .expect("loaded aot program") @@ -501,6 +522,74 @@ mod tests { )); } + #[test] + fn aot_artifact_decode_rejects_previous_native_layout_abi() { + let mut bc = BytecodeBuilder::new(); + bc.ret(); + let mut vm = Vm::new(Program::new(Vec::new(), bc.finish())); + vm.compile_aot().expect("aot compile should succeed"); + let mut encoded = vm + .encode_aot_artifact() + .expect("artifact encode should succeed"); + encoded[6..8].copy_from_slice(&6_u16.to_le_bytes()); + + assert!(matches!( + vm.load_aot_artifact(&encoded), + Err(AotArtifactError::UnsupportedAbiVersion(6)) + )); + } + + #[test] + fn aot_artifact_records_complete_native_layout_fingerprint() { + let mut bc = BytecodeBuilder::new(); + bc.ret(); + let mut vm = Vm::new(Program::new(Vec::new(), bc.finish())); + vm.compile_aot().expect("aot compile should succeed"); + let encoded = vm + .encode_aot_artifact() + .expect("artifact encode should succeed"); + let fingerprint_offset = native_layout_fingerprint_offset(&encoded); + let stored = u64::from_le_bytes( + encoded[fingerprint_offset..fingerprint_offset + 8] + .try_into() + .expect("native layout fingerprint"), + ); + let expected = native_stack_layout_fingerprint( + detect_native_stack_layout().expect("native layout should be detected"), + ); + + assert_eq!(stored, expected); + vm.load_aot_artifact(&encoded) + .expect("matching native layout should load"); + } + + #[test] + fn aot_artifact_rejects_native_layout_fingerprint_mismatch() { + let mut bc = BytecodeBuilder::new(); + bc.ret(); + let mut vm = Vm::new(Program::new(Vec::new(), bc.finish())); + vm.compile_aot().expect("aot compile should succeed"); + let mut encoded = vm + .encode_aot_artifact() + .expect("artifact encode should succeed"); + let fingerprint_offset = native_layout_fingerprint_offset(&encoded); + let stored = u64::from_le_bytes( + encoded[fingerprint_offset..fingerprint_offset + 8] + .try_into() + .expect("native layout fingerprint"), + ); + encoded[fingerprint_offset..fingerprint_offset + 8] + .copy_from_slice(&stored.wrapping_add(1).to_le_bytes()); + + assert!(matches!( + vm.load_aot_artifact(&encoded), + Err(AotArtifactError::IncompatibleRuntime { + field: "native stack layout fingerprint", + .. + }) + )); + } + #[test] fn aot_artifact_decode_rejects_incompatible_program_hash() { let mut first_bc = BytecodeBuilder::new(); @@ -571,7 +660,7 @@ mod tests { } #[test] - fn aot_artifact_v7_roundtrips_callable_metadata_and_rejects_old_revisions() { + fn aot_artifact_v8_roundtrips_callable_metadata_and_rejects_old_revisions() { let compiled = crate::compile_source_for_repl("pub fn add_one(value: int) -> int { value + 1 }") .expect("callable program should compile"); @@ -580,20 +669,20 @@ mod tests { let encoded = vm .encode_aot_artifact() .expect("artifact encode should succeed"); - assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 7); - assert_eq!(u16::from_le_bytes([encoded[6], encoded[7]]), 6); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 8); + assert_eq!(u16::from_le_bytes([encoded[6], encoded[7]]), 8); let mut old_format = encoded.clone(); - old_format[4..6].copy_from_slice(&6u16.to_le_bytes()); + old_format[4..6].copy_from_slice(&7u16.to_le_bytes()); assert!(matches!( Vm::new_from_aot_artifact_with_jit_config(&old_format, JitConfig::default()), - Err(AotArtifactError::UnsupportedVersion(6)) + Err(AotArtifactError::UnsupportedVersion(7)) )); let mut old_abi = encoded.clone(); - old_abi[6..8].copy_from_slice(&5u16.to_le_bytes()); + old_abi[6..8].copy_from_slice(&7u16.to_le_bytes()); assert!(matches!( Vm::new_from_aot_artifact_with_jit_config(&old_abi, JitConfig::default()), - Err(AotArtifactError::UnsupportedAbiVersion(5)) + Err(AotArtifactError::UnsupportedAbiVersion(7)) )); let mut standalone = @@ -613,4 +702,69 @@ mod tests { Value::Int(42) ); } + + #[test] + fn aot_artifact_v8_roundtrips_direct_call_script_program() { + // A real direct-only program: the root body calls a named function + // through `CallScript` and the callee is a native AOT body, so the + // artifact must embed both the callable metadata and the executable + // AOT code for the direct path. + let source = r#" + fn bump(value: int) -> int { value + 1 } + let mut i = 0; + let mut total = 0; + while i < 16 { + total = bump(total); + i = i + 1; + } + total; + "#; + let compiled = + crate::compile_source_for_repl(source).expect("direct call program should compile"); + assert!( + compiled + .program + .code + .contains(&(crate::OpCode::CallScript as u8)), + "expected the root body to embed CallScript bytecode" + ); + + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.compile_aot().expect("aot compile should succeed"); + let encoded = vm + .encode_aot_artifact() + .expect("artifact encode should succeed"); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 8); + assert_eq!(u16::from_le_bytes([encoded[6], encoded[7]]), 8); + + let mut old_format = encoded.clone(); + old_format[4..6].copy_from_slice(&7u16.to_le_bytes()); + assert!(matches!( + Vm::new_from_aot_artifact_with_jit_config(&old_format, JitConfig::default()), + Err(AotArtifactError::UnsupportedVersion(7)) + )); + + let mut standalone = + Vm::new_from_aot_artifact_with_jit_config(&encoded, JitConfig::default()) + .expect("standalone direct artifact should load"); + assert!( + standalone.has_aot_program(), + "standalone vm should install aot" + ); + assert_eq!( + standalone.run().expect("direct call program should run"), + VmStatus::Halted + ); + assert_eq!(standalone.stack(), &[Value::Int(16)]); + assert!( + standalone.aot_exec_count() > 0, + "standalone artifact should execute through the native AOT path: {}", + standalone.dump_aot_info() + ); + assert!( + !standalone.dump_aot_info().contains("interpreter-boundary"), + "standalone artifact should not fall back to the interpreter: {}", + standalone.dump_aot_info() + ); + } } diff --git a/src/vm/aot/cfg.rs b/src/vm/aot/cfg.rs index f0f1f886..2834ea47 100644 --- a/src/vm/aot/cfg.rs +++ b/src/vm/aot/cfg.rs @@ -47,6 +47,12 @@ pub(crate) enum AotBlockTerminal { call_ip: usize, resume_ip: usize, }, + CallScript { + prototype_id: u32, + argc: u8, + call_ip: usize, + resume_ip: usize, + }, InterpreterExit { exit_ip: usize, }, @@ -56,9 +62,11 @@ pub(crate) enum AotBlockTerminal { impl AotBlockTerminal { pub(crate) fn successor_ips(&self) -> Vec { match self { - Self::Return | Self::CallValue { .. } | Self::InterpreterExit { .. } | Self::Stop => { - Vec::new() - } + Self::Return + | Self::CallValue { .. } + | Self::CallScript { .. } + | Self::InterpreterExit { .. } + | Self::Stop => Vec::new(), Self::Jump { target_ip } => vec![*target_ip], Self::ConditionalJump { target_ip, @@ -129,6 +137,16 @@ pub(crate) fn build_cfg(program: &Program) -> Result { call_ip: ip, resume_ip: next_ip, }), + OpCode::CallScript => Some(AotBlockTerminal::CallScript { + prototype_id: u32::from_le_bytes( + code[ip + 1..ip + 5] + .try_into() + .expect("callscript operand width validated by bounds decoder"), + ), + argc: code[ip + 5], + call_ip: ip, + resume_ip: next_ip, + }), _ if next_ip == code.len() => Some(AotBlockTerminal::Stop), _ if Some(next_ip) == next_block_start => { validate_fallthrough_region(®ions, ip, next_ip)?; @@ -183,7 +201,7 @@ fn collect_block_starts( starts.insert(next_ip); } } - OpCode::CallValue => { + OpCode::CallValue | OpCode::CallScript => { if next_ip < code.len() { starts.insert(next_ip); } diff --git a/src/vm/aot/compile.rs b/src/vm/aot/compile.rs index c8896093..39593028 100644 --- a/src/vm/aot/compile.rs +++ b/src/vm/aot/compile.rs @@ -21,10 +21,11 @@ use crate::vm::native::{ clear_value_slot_entry_address, clone_value_signature, clone_value_to_slot_entry_address, collection_get_signature, collection_mutation_signature, collection_set_entry_address, copy_bytes_entry_address, copy_bytes_signature, detect_native_stack_layout, - enter_call_value_entry_address, enter_call_value_signature, entry_signature, - frame_state_entry_address, frame_state_signature, free_buffer_signature, helper_entry_offset, - helper_signature, init_null_value_slot_entry_address, jump_with_status, - leave_frame_entry_address, leave_frame_signature, pack_shared_signature, resolve_offsets, + enter_call_script_entry_address, enter_call_script_signature, enter_call_value_entry_address, + enter_call_value_signature, entry_signature, frame_state_entry_address, frame_state_signature, + free_buffer_signature, helper_entry_offset, helper_signature, + init_null_value_slot_entry_address, jump_with_status, leave_frame_entry_address, + leave_frame_signature, pack_shared_signature, resolve_offsets, restore_active_exit_state_entry_address, restore_exit_signature, restore_exit_state_entry_address, shared_array_from_buffer_entry_address, shared_bytes_from_buffer_entry_address, shared_string_from_buffer_entry_address, @@ -332,6 +333,7 @@ struct AotDeoptHelperRefs { interrupt_ref: cranelift_codegen::ir::SigRef, frame_state_ref: cranelift_codegen::ir::SigRef, enter_call_value_ref: cranelift_codegen::ir::SigRef, + enter_call_script_ref: cranelift_codegen::ir::SigRef, leave_frame_ref: cranelift_codegen::ir::SigRef, clone_value_ref: cranelift_codegen::ir::SigRef, value_eq_ref: cranelift_codegen::ir::SigRef, @@ -349,6 +351,7 @@ struct AotDeoptHelperAddrs { aot_interrupt: usize, frame_state: usize, enter_call_value: usize, + enter_call_script: usize, leave_frame: usize, clone_value: usize, value_eq: usize, @@ -500,6 +503,7 @@ fn compile_ssa( let alloc_buffer_sig = alloc_buffer_signature(pointer_type, call_conv); let frame_state_sig = frame_state_signature(pointer_type, call_conv); let enter_call_value_sig = enter_call_value_signature(pointer_type, call_conv); + let enter_call_script_sig = enter_call_script_signature(pointer_type, call_conv); let leave_frame_sig = leave_frame_signature(pointer_type, call_conv); let free_buffer_sig = free_buffer_signature(pointer_type, call_conv); let pack_shared_sig = pack_shared_signature(pointer_type, call_conv); @@ -530,6 +534,7 @@ fn compile_ssa( aot_interrupt: aot_call_boundary_interrupt_entry_address(), frame_state: frame_state_entry_address(), enter_call_value: enter_call_value_entry_address(), + enter_call_script: enter_call_script_entry_address(), leave_frame: leave_frame_entry_address(), clone_value: clone_value_to_slot_entry_address(), value_eq: value_eq_entry_address(), @@ -566,7 +571,7 @@ fn compile_ssa( let ctx_setup_elapsed = ctx_setup_started.elapsed(); let vm_ip_offset = - i32::try_from(std::mem::offset_of!(Vm, ip)).expect("Vm::ip offset must fit i32"); + i32::try_from(std::mem::offset_of!(Vm, instance.ip)).expect("Vm::ip offset must fit i32"); let code_len_i64 = i64::try_from(program.code.len()) .map_err(|_| AotCompileError::Codegen("program length does not fit i64".to_string()))?; @@ -587,6 +592,7 @@ fn compile_ssa( interrupt_ref: b.import_signature(interrupt_sig), frame_state_ref: b.import_signature(frame_state_sig), enter_call_value_ref: b.import_signature(enter_call_value_sig), + enter_call_script_ref: b.import_signature(enter_call_script_sig), leave_frame_ref: b.import_signature(leave_frame_sig), clone_value_ref: b.import_signature(clone_value_sig), value_eq_ref: b.import_signature(value_eq_sig), @@ -1622,6 +1628,58 @@ fn lower_aot_ssa_terminator( let status = b.inst_results(call)[0]; jump_with_status(b, exit_block, status); } + AotSsaTerminator::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + stack, + locals, + } => { + materialize_state_to_vm( + b, + vm_ptr, + exit_block, + pointer_type, + layout, + helper_refs, + helper_addrs, + stack, + locals, + values, + *call_ip, + )?; + emit_call_boundary_interrupt( + b, + vm_ptr, + helper_refs.interrupt_ref, + helper_addrs.aot_interrupt, + pointer_type, + exit_block, + )?; + let helper_ptr = iconst_ptr_from_addr(b, pointer_type, helper_addrs.enter_call_script)?; + let prototype_id = b.ins().iconst(types::I64, i64::from(*prototype_id)); + let argc = b.ins().iconst(types::I64, i64::from(*argc)); + let call_ip = b.ins().iconst( + types::I64, + i64::try_from(*call_ip).map_err(|_| { + AotCompileError::Codegen("callscript ip does not fit i64".to_string()) + })?, + ); + let resume_ip = b.ins().iconst( + types::I64, + i64::try_from(*resume_ip).map_err(|_| { + AotCompileError::Codegen("callscript resume ip does not fit i64".to_string()) + })?, + ); + let call = b.ins().call_indirect( + helper_refs.enter_call_script_ref, + helper_ptr, + &[vm_ptr, prototype_id, argc, call_ip, resume_ip], + ); + let status = b.inst_results(call)[0]; + jump_with_status(b, exit_block, status); + } AotSsaTerminator::InterpreterBoundary { ip, stack, locals } => { materialize_state_to_vm( b, diff --git a/src/vm/aot/ir.rs b/src/vm/aot/ir.rs index da1921ae..5bf202f0 100644 --- a/src/vm/aot/ir.rs +++ b/src/vm/aot/ir.rs @@ -325,6 +325,15 @@ fn lower_block( kind: "script callable frame operation requires runtime lowering", }); } + OpCode::CallScript => { + // `CallScript` is lowered as an explicit terminal; a + // mid-block occurrence means the CFG is inconsistent. + return Err(AotLowerError::InvalidImmediate { + ip, + opcode, + kind: "unexpected script call terminal in lowered instruction stream", + }); + } OpCode::Ret | OpCode::Br | OpCode::Brfalse => { return Err(AotLowerError::InvalidImmediate { ip, @@ -505,6 +514,16 @@ fn is_explicit_terminal_opcode( && read_u8(code, ip + 1) == Some(*argc) && ip == *call_ip && next_ip == *resume_ip), + AotBlockTerminal::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + } => Ok(opcode == OpCode::CallScript + && read_u32(code, ip + 1) == Some(*prototype_id) + && read_u8(code, ip + 5) == Some(*argc) + && ip == *call_ip + && next_ip == *resume_ip), AotBlockTerminal::InterpreterExit { exit_ip } => { Ok(opcode == OpCode::CallValue && ip == *exit_ip) } diff --git a/src/vm/aot/runtime.rs b/src/vm/aot/runtime.rs index 13029006..abfcb7e4 100644 --- a/src/vm/aot/runtime.rs +++ b/src/vm/aot/runtime.rs @@ -8,32 +8,33 @@ use crate::vm::{ExecOutcome, Vm, VmError, VmResult}; impl Vm { pub fn compile_aot(&mut self) -> VmResult<()> { - self.aot_program = Some(compile_program(self.program())?); - self.aot_exec_count = 0; + self.engine.aot_program = Some(compile_program(self.program())?); + self.engine.aot_exec_count = 0; Ok(()) } pub fn clear_aot(&mut self) { - self.aot_program = None; - self.aot_exec_count = 0; + self.engine.aot_program = None; + self.engine.aot_exec_count = 0; } pub fn has_aot_program(&self) -> bool { - self.aot_program.is_some() + self.engine.aot_program.is_some() } pub fn aot_exec_count(&self) -> u64 { - self.aot_exec_count + self.engine.aot_exec_count } pub fn aot_resume_ips(&self) -> Option<&[usize]> { - self.aot_program + self.engine + .aot_program .as_ref() .map(|program| program.resume_ips.as_ref()) } pub fn dump_aot_info(&self) -> String { - let Some(program) = self.aot_program.as_ref() else { + let Some(program) = self.engine.aot_program.as_ref() else { return "whole-program aot: disabled\n".to_string(); }; @@ -43,7 +44,10 @@ impl Vm { " native codegen backend: {}\n", selected_codegen_backend() )); - out.push_str(&format!(" aot executions: {}\n", self.aot_exec_count)); + out.push_str(&format!( + " aot executions: {}\n", + self.engine.aot_exec_count + )); out.push_str(&format!(" code_bytes={}\n", program.code.len())); out.push_str(&format!( " lowering={}\n", @@ -58,38 +62,51 @@ impl Vm { } pub(crate) fn execute_aot_entry(&mut self) -> VmResult { - let Some(entry) = self.aot_program.as_ref().map(|program| program.entry) else { + if !self.host.allow_default_host_capabilities { + self.engine.aot_interpreter_boundary_hit = true; + return Ok(ExecOutcome::Continue); + } + let Some(entry) = self + .engine + .aot_program + .as_ref() + .map(|program| program.entry) + else { return Ok(ExecOutcome::Continue); }; clear_bridge_error(); unsafe { crate::vm::native::prepare_for_execution() }; let status = unsafe { entry(self as *mut Vm) }; - self.aot_exec_count = self.aot_exec_count.saturating_add(1); + self.engine.aot_exec_count = self.engine.aot_exec_count.saturating_add(1); match status { STATUS_CONTINUE | STATUS_LINKED_CONTINUE => Ok(ExecOutcome::Continue), STATUS_HALTED => Ok(ExecOutcome::Halted), STATUS_YIELDED => { - self.last_yield_reason = Some(super::super::VmYieldReason::Host); + self.instance.last_yield_reason = Some(super::super::VmYieldReason::Host); Ok(ExecOutcome::Yielded) } STATUS_WAITING => { - let op_id = self.waiting_host_op.map(|op| op.op_id).ok_or_else(|| { - VmError::JitNative( - "aot call bridge reported waiting without a pending op".to_string(), - ) - })?; + let op_id = self + .instance + .waiting_host_op + .map(|op| op.op_id) + .ok_or_else(|| { + VmError::JitNative( + "aot call bridge reported waiting without a pending op".to_string(), + ) + })?; Ok(ExecOutcome::Waiting(op_id)) } - STATUS_OUT_OF_FUEL => match self.interrupt_mode { + STATUS_OUT_OF_FUEL => match self.run_ctx.interrupt_mode { super::super::InterruptMode::Fuel => Err(VmError::OutOfFuel { needed: 1, - remaining: self.fuel_remaining, + remaining: self.run_ctx.fuel_remaining, }), super::super::InterruptMode::Epoch => Err(VmError::EpochDeadlineReached { current: self.current_epoch(), - deadline: self.epoch_deadline, + deadline: self.run_ctx.epoch_deadline, }), super::super::InterruptMode::None => Err(VmError::JitNative( "aot interruption checkpoint fired while interruption was disabled".to_string(), @@ -99,18 +116,18 @@ impl Vm { if let Some(err) = take_bridge_error() { return Err(err); } - if self.ip == self.program.code.len() { + if self.instance.ip == self.program.code.len() { return Err(VmError::BytecodeBounds); } Err(VmError::JitNative(format!( "aot entry reported failure without VmError (ip={} stack_len={} aot={})", - self.ip, - self.stack.len(), + self.instance.ip, + self.instance.stack.len(), self.has_aot_program() ))) } STATUS_TRACE_EXIT => { - self.aot_interpreter_boundary_hit = true; + self.engine.aot_interpreter_boundary_hit = true; Ok(ExecOutcome::Continue) } other => Err(VmError::JitNative(format!( diff --git a/src/vm/aot/ssa.rs b/src/vm/aot/ssa.rs index b7c0e9e9..cbe70092 100644 --- a/src/vm/aot/ssa.rs +++ b/src/vm/aot/ssa.rs @@ -420,6 +420,14 @@ pub(crate) enum AotSsaTerminator { stack: Vec, locals: Vec, }, + CallScript { + prototype_id: u32, + argc: u8, + call_ip: usize, + resume_ip: usize, + stack: Vec, + locals: Vec, + }, InterpreterBoundary { ip: usize, stack: Vec, @@ -744,6 +752,7 @@ fn verify_terminator( } AotSsaTerminator::CallBoundary { stack, locals, .. } | AotSsaTerminator::CallValue { stack, locals, .. } + | AotSsaTerminator::CallScript { stack, locals, .. } | AotSsaTerminator::InterpreterBoundary { stack, locals, .. } | AotSsaTerminator::Return { stack, locals, .. } => { for materialization in stack.iter().chain(locals.iter()) { @@ -852,6 +861,14 @@ enum ProcessResult { frame: Frame, resume_frame: Frame, }, + CallScript { + prototype_id: u32, + argc: u8, + call_ip: usize, + resume_ip: usize, + frame: Frame, + resume_frame: Frame, + }, InterpreterBoundary { ip: usize, frame: Frame, @@ -961,6 +978,9 @@ impl<'a> Builder<'a> { } if let AotBlockTerminal::CallValue { call_ip, resume_ip, .. + } + | AotBlockTerminal::CallScript { + call_ip, resume_ip, .. } = block.terminal { checkpoint_ips.insert(call_ip); @@ -1114,6 +1134,21 @@ impl<'a> Builder<'a> { stack: materialize_values(&frame.stack), locals: materialize_values(&frame.locals), }, + ProcessResult::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + frame, + resume_frame: _, + } => AotSsaTerminator::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + stack: materialize_values(&frame.stack), + locals: materialize_values(&frame.locals), + }, ProcessResult::InterpreterBoundary { ip, frame } => { AotSsaTerminator::InterpreterBoundary { ip, @@ -1215,6 +1250,13 @@ impl<'a> Builder<'a> { } => { self.merge_shape(resume_ip, resume_frame.shape(), &mut queue)?; } + ProcessResult::CallScript { + resume_ip, + resume_frame, + .. + } => { + self.merge_shape(resume_ip, resume_frame.shape(), &mut queue)?; + } ProcessResult::InterpreterBoundary { .. } | ProcessResult::Return { .. } | ProcessResult::Stop { .. } => {} @@ -1507,6 +1549,31 @@ impl<'a> Builder<'a> { resume_frame, }) } + AotBlockTerminal::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + } => { + // `CallScript` pushes no callable operand: the arguments are + // exactly the top `argc` stack values. + let mut resume_frame = frame.clone(); + for _ in 0..usize::from(*argc) { + resume_frame.pop(*call_ip, "callscript")?; + } + let return_repr = value_type_repr(operand_types_at(self.program, *call_ip).1); + resume_frame.stack.push(FrameValue { + value: AotSsaValue::new(AotSsaValueId::new(0), return_repr), + }); + Ok(ProcessResult::CallScript { + prototype_id: *prototype_id, + argc: *argc, + call_ip: *call_ip, + resume_ip: *resume_ip, + frame: frame.clone(), + resume_frame, + }) + } AotBlockTerminal::Return => Ok(ProcessResult::Return { ip: block .terminal_ip @@ -1564,7 +1631,8 @@ fn terminal_ip(block: &super::ir::AotIrBlock) -> Option { block.end_ip.checked_sub(5) } AotBlockTerminal::Fallthrough { .. } | AotBlockTerminal::Stop => None, - AotBlockTerminal::CallValue { call_ip, .. } => Some(call_ip), + AotBlockTerminal::CallValue { call_ip, .. } + | AotBlockTerminal::CallScript { call_ip, .. } => Some(call_ip), AotBlockTerminal::InterpreterExit { exit_ip } => Some(exit_ip), } } diff --git a/src/vm/async_host/mod.rs b/src/vm/async_host/mod.rs new file mode 100644 index 00000000..68ec8e80 --- /dev/null +++ b/src/vm/async_host/mod.rs @@ -0,0 +1,361 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; + +use super::*; + +type HostVmCompletion = Box VmResult + Send + 'static>; + +pub enum HostFutureOutput { + Return(T), + VmCompletion(HostVmCompletion), +} + +impl HostFutureOutput { + pub fn returning(value: T) -> Self { + Self::Return(value) + } + + pub fn complete(completion: impl FnOnce(&mut Vm) -> VmResult + Send + 'static) -> Self { + Self::VmCompletion(Box::new(completion)) + } + + pub fn map( + self, + map: impl FnOnce(T) -> U + Send + 'static, + ) -> HostFutureOutput + where + T: Send + 'static, + { + match self { + Self::Return(value) => HostFutureOutput::Return(map(value)), + Self::VmCompletion(completion) => { + HostFutureOutput::VmCompletion(Box::new(move |vm| completion(vm).map(map))) + } + } + } +} + +impl HostFutureOutput { + fn finish(self, vm: &mut Vm) -> VmResult { + match self { + Self::Return(values) => Ok(values), + Self::VmCompletion(completion) => completion(vm), + } + } +} + +impl From for HostFutureOutput { + fn from(values: CallReturn) -> Self { + Self::Return(values) + } +} + +pub type HostFuture = Pin> + Send + 'static>>; + +pub trait CaptureAsyncHostContext: Send + 'static + Sized { + fn capture(vm: &mut Vm) -> VmResult; + + fn capture_with_args(vm: &mut Vm, _args: &[Value]) -> VmResult { + Self::capture(vm) + } +} + +pub trait HostAsyncBridge: Send { + fn submit_op(&mut self, _op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + Err(VmError::HostError( + "async host bridge does not accept submitted futures".to_string(), + )) + } + + fn poll_op(&mut self, op_id: HostOpId, cx: &mut Context<'_>) -> Poll>; + + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + self.poll_op(op_id, cx) + .map(|result| result.map(HostFutureOutput::Return)) + } + + fn cancel_op(&mut self, _op_id: HostOpId) {} + + fn cancel_op_with_reason(&mut self, op_id: HostOpId, _reason: CancellationReason) { + self.cancel_op(op_id); + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct WaitingHostOp { + pub(super) op_id: HostOpId, +} + +struct NoopWake; + +impl Wake for NoopWake { + fn wake(self: Arc) {} +} + +fn noop_waker() -> Waker { + Waker::from(Arc::new(NoopWake)) +} + +impl Vm { + pub fn set_async_bridge(&mut self, bridge: Box) { + self.cancel_waiting_host_op(); + self.host.async_bridge = Some(bridge); + } + + pub fn clear_async_bridge(&mut self) { + self.cancel_waiting_host_op(); + self.host.async_bridge = None; + } + + pub fn allocate_host_op_id(&mut self) -> HostOpId { + self.host + .runtime_operations + .allocate_id() + .expect("host operation id space should not be exhausted") + .raw() + } + + pub fn submit_host_future(&mut self, future: HostFuture) -> VmResult { + let op_id = self.allocate_host_op_id(); + let bridge = self.host.async_bridge.as_mut().ok_or_else(|| { + VmError::HostError("async host function requires a host async bridge".to_string()) + })?; + bridge.submit_op(op_id, future)?; + self.host.submitted_host_ops.insert(op_id); + Ok(CallOutcome::Pending(op_id)) + } + + pub fn waiting_host_op_id(&self) -> Option { + self.instance.waiting_host_op.map(|op| op.op_id) + } + + pub fn cancel_waiting_host_op(&mut self) { + self.cancel_waiting_host_op_with_reason( + crate::builtins::runtime::cancellation::CancellationReason::Requested, + ); + } + + pub(crate) fn cancel_waiting_host_op_with_reason( + &mut self, + reason: crate::builtins::runtime::cancellation::CancellationReason, + ) { + let Some(waiting) = self.instance.waiting_host_op.take() else { + return; + }; + let Ok(operation_id) = + crate::builtins::runtime::cancellation::OperationId::from_raw(waiting.op_id) + else { + return; + }; + let owner = self + .host + .runtime_operations + .get(operation_id) + .ok() + .map(|operation| operation.owner()); + if owner == Some(crate::builtins::runtime::cancellation::OperationOwner::HostBridge) { + if let Some(bridge) = self.host.async_bridge.as_mut() { + bridge.cancel_op_with_reason(waiting.op_id, reason); + } + self.host.submitted_host_ops.remove(&waiting.op_id); + let _ = self.host.runtime_operations.cancel(operation_id, reason); + } else { + crate::builtins::runtime::cancel_builtin_io_op_with_reason(self, waiting.op_id, reason); + } + } + + pub fn complete_host_op( + &mut self, + op_id: HostOpId, + values: impl Into, + ) -> VmResult<()> { + let waiting = self.instance.waiting_host_op.ok_or_else(|| { + VmError::HostError(format!( + "host op {op_id} completed but vm is not waiting on any op", + )) + })?; + if waiting.op_id != op_id { + return Err(VmError::HostError(format!( + "host op {op_id} completed while vm waits on {}", + waiting.op_id + ))); + } + let operation_id = crate::builtins::runtime::cancellation::OperationId::from_raw(op_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + let operation = self + .host + .runtime_operations + .get(operation_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + if operation.owner() != crate::builtins::runtime::cancellation::OperationOwner::HostBridge { + return Err(VmError::HostError(format!( + "host bridge cannot complete runtime-owned operation {op_id}", + ))); + } + self.host + .runtime_operations + .complete(operation_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + if self.host.submitted_host_ops.remove(&op_id) + && let Some(bridge) = self.host.async_bridge.as_mut() + { + bridge.cancel_op(op_id); + } + self.complete_waiting_host_op(op_id, values.into()) + } + + pub fn poll_waiting_host_op(&mut self, cx: &mut Context<'_>) -> Poll> { + let Some(waiting) = self.instance.waiting_host_op else { + return Poll::Ready(Ok(())); + }; + let operation_id = + match crate::builtins::runtime::cancellation::OperationId::from_raw(waiting.op_id) { + Ok(operation_id) => operation_id, + Err(error) => return Poll::Ready(Err(VmError::HostError(error.to_string()))), + }; + let operation = match self.host.runtime_operations.get(operation_id) { + Ok(operation) => operation, + Err(error) => return Poll::Ready(Err(VmError::HostError(error.to_string()))), + }; + let host_bridge_owned = + operation.owner() == crate::builtins::runtime::cancellation::OperationOwner::HostBridge; + + let poll_result = if host_bridge_owned { + let bridge_ptr = match self.host.async_bridge.as_mut() { + Some(bridge) => bridge.as_mut() as *mut dyn HostAsyncBridge, + None => { + return Poll::Ready(Err(VmError::HostError(format!( + "vm waiting on host op {} without an async bridge", + waiting.op_id + )))); + } + }; + if self.host.submitted_host_ops.contains(&waiting.op_id) { + unsafe { (&mut *bridge_ptr).poll_submitted_op(waiting.op_id, cx) } + } else { + unsafe { (&mut *bridge_ptr).poll_op(waiting.op_id, cx) } + .map(|result| result.map(HostFutureOutput::Return)) + } + } else { + crate::builtins::runtime::poll_builtin_io_op(self, waiting.op_id, cx) + .map(|result| result.map(HostFutureOutput::Return)) + }; + + match poll_result { + Poll::Pending => Poll::Pending, + Poll::Ready(Ok(output)) => { + let values = match output.finish(self) { + Ok(values) => values, + Err(err) => { + if host_bridge_owned { + self.host.submitted_host_ops.remove(&waiting.op_id); + let runtime_error = crate::builtins::runtime::error::RuntimeError::new( + crate::builtins::runtime::error::RuntimeErrorCode::OperationFailed, + "runtime::host_bridge", + err.to_string(), + ) + .with_value(waiting.op_id); + let _ = self + .host + .runtime_operations + .fail(operation_id, runtime_error); + } + self.instance.waiting_host_op = None; + return Poll::Ready(Err(err)); + } + }; + if host_bridge_owned { + self.host + .runtime_operations + .complete(operation_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + self.host.submitted_host_ops.remove(&waiting.op_id); + } + self.complete_waiting_host_op(waiting.op_id, values)?; + Poll::Ready(Ok(())) + } + Poll::Ready(Err(err)) => { + if host_bridge_owned { + self.host.submitted_host_ops.remove(&waiting.op_id); + let runtime_error = crate::builtins::runtime::error::RuntimeError::new( + crate::builtins::runtime::error::RuntimeErrorCode::OperationFailed, + "runtime::host_bridge", + err.to_string(), + ) + .with_value(waiting.op_id); + let _ = self + .host + .runtime_operations + .fail(operation_id, runtime_error); + } + self.instance.waiting_host_op = None; + Poll::Ready(Err(err)) + } + } + } + + pub async fn await_waiting_host_op(&mut self) -> VmResult<()> { + std::future::poll_fn(|cx| self.poll_waiting_host_op(cx)).await + } + + pub fn wait_for_host_op_blocking(&mut self) -> VmResult<()> { + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + loop { + match self.poll_waiting_host_op(&mut cx) { + Poll::Ready(result) => return result, + Poll::Pending => { + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + #[cfg(target_arch = "wasm32")] + { + return Err(VmError::HostError( + "blocking host-op wait is unsupported on wasm32 runtime".to_string(), + )); + } + } + } + } + } + + pub fn wait_for_host_op_blocking_with_cancel(&mut self, mut should_cancel: F) -> VmResult<()> + where + F: FnMut() -> bool, + { + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + loop { + if should_cancel() { + let cancellation_result = self + .run_ctx + .cancel(crate::builtins::runtime::cancellation::CancellationReason::Requested); + self.cancel_waiting_host_op(); + cancellation_result?; + return Err(VmError::HostError("host operation cancelled".to_string())); + } + match self.poll_waiting_host_op(&mut cx) { + Poll::Ready(result) => return result, + Poll::Pending => { + #[cfg(not(target_arch = "wasm32"))] + { + std::thread::sleep(std::time::Duration::from_millis(1)); + } + #[cfg(target_arch = "wasm32")] + { + return Err(VmError::HostError( + "blocking host-op wait is unsupported on wasm32 runtime".to_string(), + )); + } + } + } + } + } +} diff --git a/src/vm/capability.rs b/src/vm/capability.rs new file mode 100644 index 00000000..9c60be11 --- /dev/null +++ b/src/vm/capability.rs @@ -0,0 +1,168 @@ +use crate::builtins::BuiltinFunction; + +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; +const PROFILE_VERSION: &[u8] = b"rustscript-capability-profile-v2"; + +/// Immutable authorization policy for privileged builtin calls and host imports. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CapabilityProfile { + allow_all_builtins: bool, + allow_all_host_imports: bool, + allowed_builtin_calls: Vec, + allowed_host_imports: Vec, + fingerprint: u64, +} + +impl CapabilityProfile { + pub fn builder() -> CapabilityProfileBuilder { + CapabilityProfileBuilder::default() + } + + pub fn deny_all() -> Self { + CapabilityProfileBuilder::default().build() + } + + pub fn allow_all() -> Self { + CapabilityProfileBuilder { + allow_all_builtins: true, + allow_all_host_imports: true, + ..CapabilityProfileBuilder::default() + } + .build() + } + + pub fn fingerprint(&self) -> u64 { + self.fingerprint + } + + pub fn allows_builtin(&self, builtin: BuiltinFunction) -> bool { + self.allow_all_builtins + || self + .allowed_builtin_calls + .binary_search(&builtin.call_index()) + .is_ok() + } + + pub fn allows_host_import(&self, name: &str) -> bool { + self.allow_all_host_imports + || self + .allowed_host_imports + .binary_search_by(|candidate| candidate.as_str().cmp(name)) + .is_ok() + } + + pub(crate) fn allowed_builtin_calls(&self) -> &[u16] { + &self.allowed_builtin_calls + } + + pub(crate) fn allows_all_builtins(&self) -> bool { + self.allow_all_builtins + } + + pub(crate) fn allows_all_host_imports(&self) -> bool { + self.allow_all_host_imports + } + + pub(crate) fn with_builtin(&self, builtin: BuiltinFunction) -> Self { + let mut builder = CapabilityProfileBuilder { + allow_all_builtins: self.allow_all_builtins, + allow_all_host_imports: self.allow_all_host_imports, + allowed_builtin_calls: self.allowed_builtin_calls.clone(), + allowed_host_imports: self.allowed_host_imports.clone(), + }; + builder.allowed_builtin_calls.push(builtin.call_index()); + builder.build() + } + + pub(crate) fn with_host_import(&self, name: &str) -> Self { + let mut builder = CapabilityProfileBuilder { + allow_all_builtins: self.allow_all_builtins, + allow_all_host_imports: self.allow_all_host_imports, + allowed_builtin_calls: self.allowed_builtin_calls.clone(), + allowed_host_imports: self.allowed_host_imports.clone(), + }; + builder.allowed_host_imports.push(name.to_string()); + builder.build() + } +} + +impl Default for CapabilityProfile { + fn default() -> Self { + Self::deny_all() + } +} + +#[derive(Clone, Debug, Default)] +pub struct CapabilityProfileBuilder { + allow_all_builtins: bool, + allow_all_host_imports: bool, + allowed_builtin_calls: Vec, + allowed_host_imports: Vec, +} + +impl CapabilityProfileBuilder { + pub fn allow_builtin(mut self, builtin: BuiltinFunction) -> Self { + self.allowed_builtin_calls.push(builtin.call_index()); + self + } + + pub fn allow_host_import(mut self, name: impl Into) -> Self { + self.allowed_host_imports.push(name.into()); + self + } + + pub fn build(mut self) -> CapabilityProfile { + self.allowed_builtin_calls.sort_unstable(); + self.allowed_builtin_calls.dedup(); + self.allowed_host_imports.sort(); + self.allowed_host_imports.dedup(); + let fingerprint = fingerprint( + self.allow_all_builtins, + self.allow_all_host_imports, + &self.allowed_builtin_calls, + &self.allowed_host_imports, + ); + CapabilityProfile { + allow_all_builtins: self.allow_all_builtins, + allow_all_host_imports: self.allow_all_host_imports, + allowed_builtin_calls: self.allowed_builtin_calls, + allowed_host_imports: self.allowed_host_imports, + fingerprint, + } + } +} + +fn fingerprint( + allow_all_builtins: bool, + allow_all_host_imports: bool, + builtin_calls: &[u16], + host_imports: &[String], +) -> u64 { + let mut value = FNV_OFFSET_BASIS; + update_fingerprint(&mut value, PROFILE_VERSION); + update_fingerprint( + &mut value, + &[ + u8::from(allow_all_builtins), + u8::from(allow_all_host_imports), + ], + ); + update_fingerprint(&mut value, &(builtin_calls.len() as u64).to_le_bytes()); + for call in builtin_calls { + update_fingerprint(&mut value, &call.to_le_bytes()); + } + update_fingerprint(&mut value, &(host_imports.len() as u64).to_le_bytes()); + for name in host_imports { + update_fingerprint(&mut value, &(name.len() as u64).to_le_bytes()); + update_fingerprint(&mut value, name.as_bytes()); + } + value +} + +fn update_fingerprint(state: &mut u64, bytes: &[u8]) { + for byte in bytes { + *state ^= u64::from(*byte); + *state = state.wrapping_mul(FNV_PRIME); + } +} diff --git a/src/vm/engine.rs b/src/vm/engine.rs new file mode 100644 index 00000000..33acefe9 --- /dev/null +++ b/src/vm/engine.rs @@ -0,0 +1,141 @@ +//! Backend engine state. +//! +//! [`Engine`] owns the code-generation backends and their caches: the trace +//! JIT engine, native traces and their counters, the optional AOT program, +//! the regex cache, program-derived decode caches, and code-generation +//! telemetry. It holds no per-run interpreter state and no host bindings, so +//! it can be shared across runs (and, by construction, reused by any number of +//! instances that never share stacks or resources). +//! +//! Native ABI note: the JIT/AOT code generators read a handful of fields by +//! machine offset through `std::mem::offset_of!(Vm, engine.)`. The +//! field set and the offsets are part of the native ABI; see +//! `crate::vm::native::layout`. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::builtins::runtime::regex::RegexCache; +use crate::bytecode::{DecodedInstructionData, Program}; +use crate::vm::aot; +use crate::vm::jit; +use crate::vm::native; + +/// Engine-owned backend configuration, caches, and code-generation telemetry. +/// +/// Thread safety: `Engine` is not shared between threads (`TraceJitEngine` is +/// not `Sync`); one VM facade owns one engine. Clone semantics: `Engine` is +/// intentionally not `Clone` — duplicating it would duplicate native traces +/// and JIT bookkeeping that are keyed to one execution identity. +pub(crate) struct Engine { + pub(crate) jit: jit::TraceJitEngine, + pub(crate) native_traces: Vec>, + pub(crate) native_trace_exec_count: u64, + pub(crate) aot_program: Option, + pub(crate) aot_exec_count: u64, + pub(crate) aot_interpreter_boundary_hit: bool, + pub(crate) jit_native_region_entry_count: u64, + pub(crate) jit_native_region_edge_count: u64, + pub(crate) jit_native_direct_link_count: u64, + pub(crate) jit_native_direct_links_enabled: bool, + pub(crate) jit_native_direct_cross_frame_enabled: bool, + pub(crate) jit_native_active_direct_trace_id: usize, + pub(crate) jit_native_direct_escape_streak: u16, + pub(crate) jit_native_direct_region_fallback: bool, + pub(crate) jit_native_compile_time_ns: u64, + pub(crate) jit_native_region_compile_time_ns: u64, + pub(crate) jit_trace_exit_count: u64, + pub(crate) jit_native_loop_back_count: u64, + pub(crate) jit_native_link_handoff_count: u64, + pub(crate) jit_native_link_dispatch_depth: u32, + pub(crate) jit_helper_fallback_count: u64, + pub(crate) jit_native_bridge_stats_enabled: bool, + pub(crate) jit_native_bridge_counts: HashMap<&'static str, u64>, + pub(crate) program_cache_key: u64, + pub(crate) program_cache_key_ready: bool, + pub(crate) regex_cache: RegexCache, + pub(crate) decoded_instruction_data: Arc, + pub(crate) operand_type_hints: Option>, + // Native ABI mirrors: the JIT/AOT code generators load these addresses by + // field offset from the `Vm` facade. They are derived from the program and + // from static helper entry points, and are documented as load-bearing for + // `crate::vm::native`. + pub(crate) program_constants_ptr: usize, + #[allow(dead_code)] + pub(crate) program_constants_len: usize, + #[allow(dead_code)] + pub(crate) native_helper_fn: usize, + #[allow(dead_code)] + pub(crate) native_interrupt_helper_fn: usize, +} + +impl Engine { + /// Builds an engine for one program and JIT configuration. + pub(crate) fn new(jit_config: jit::JitConfig, program: &Program) -> Self { + Self { + jit: jit::TraceJitEngine::new(jit_config), + native_traces: Vec::new(), + native_trace_exec_count: 0, + aot_program: None, + aot_exec_count: 0, + aot_interpreter_boundary_hit: false, + jit_native_region_entry_count: 0, + jit_native_region_edge_count: 0, + jit_native_direct_link_count: 0, + jit_native_direct_links_enabled: true, + jit_native_direct_cross_frame_enabled: false, + jit_native_active_direct_trace_id: usize::MAX, + jit_native_direct_escape_streak: 0, + jit_native_direct_region_fallback: false, + jit_native_compile_time_ns: 0, + jit_native_region_compile_time_ns: 0, + jit_trace_exit_count: 0, + jit_native_loop_back_count: 0, + jit_native_link_handoff_count: 0, + jit_native_link_dispatch_depth: 0, + jit_helper_fallback_count: 0, + jit_native_bridge_stats_enabled: false, + jit_native_bridge_counts: HashMap::new(), + program_cache_key: 0, + program_cache_key_ready: false, + regex_cache: RegexCache::default(), + decoded_instruction_data: program.shared_decoded_instruction_data(), + operand_type_hints: program.shared_operand_type_hints(), + program_constants_ptr: program.constants.as_ptr() as usize, + program_constants_len: program.constants.len(), + native_helper_fn: native::helper_entry_address(), + native_interrupt_helper_fn: native::interrupt_helper_entry_address(), + } + } + + /// Returns the program cache key, computing and caching it on first use. + /// The key identifies the program for backend cache lookups; it is stable + /// for the lifetime of the engine (the program is immutable). + pub(crate) fn ensure_program_cache_key(&mut self, program: &Program) -> u64 { + if !self.program_cache_key_ready { + self.program_cache_key = super::compute_program_cache_key(program); + self.program_cache_key_ready = true; + } + self.program_cache_key + } + + /// Rewinds run-scoped backend state between runs while retaining compiled + /// artifacts: hot-entry bookkeeping and call-site profiles are cleared, + /// and the AOT boundary flag is recomputed from the compiled program. + pub(crate) fn reset_runtime_state(&mut self, program: &Program) { + self.aot_interpreter_boundary_hit = self + .aot_program + .as_ref() + .is_some_and(|compiled| compiled.interpreter_boundary_only); + self.jit.reset_runtime_backoff(); + self.jit.clear_call_site_profiles(); + let _ = program; + } + + /// Invalidates code-generation caches that may reference run-scoped + /// behavior (used when drop-contract event accounting is toggled). + pub(crate) fn invalidate_codegen_caches(&mut self) { + self.native_traces.clear(); + self.native_trace_exec_count = 0; + } +} diff --git a/src/vm/epoch.rs b/src/vm/epoch.rs index 178a1202..0c8b2d93 100644 --- a/src/vm/epoch.rs +++ b/src/vm/epoch.rs @@ -57,56 +57,40 @@ impl EpochHandle { impl Vm { #[inline(always)] pub(in crate::vm) fn charge_epoch_tick(&mut self) -> VmResult<()> { - if !self.epoch_interruption_enabled() { - return Ok(()); - } - if self.fuel_ops_until_check > 1 { - self.fuel_ops_until_check -= 1; - return Ok(()); - } - - let current = self.current_epoch(); - if current >= self.epoch_deadline { - return Err(VmError::EpochDeadlineReached { - current, - deadline: self.epoch_deadline, - }); - } - self.fuel_ops_until_check = self.fuel_check_interval; - Ok(()) + self.run_ctx.charge_epoch_tick() } #[inline(always)] pub(super) fn mark_interrupt_yield(&mut self, reason: VmYieldReason) { - self.last_yield_reason = Some(reason); + self.instance.last_yield_reason = Some(reason); if matches!(reason, VmYieldReason::Epoch) { - self.epoch_rearm_pending = true; + self.run_ctx.epoch_rearm_pending = true; } } #[inline(always)] pub(super) fn rearm_epoch_after_yield_if_needed(&mut self) { - if !self.epoch_rearm_pending { + if !self.run_ctx.epoch_rearm_pending { return; } if !self.epoch_interruption_enabled() { - self.epoch_rearm_pending = false; + self.run_ctx.epoch_rearm_pending = false; return; } - self.epoch_deadline = self + self.run_ctx.epoch_deadline = self .current_epoch() - .saturating_add(self.epoch_deadline_delta); - self.epoch_rearm_pending = false; + .saturating_add(self.run_ctx.epoch_deadline_delta); + self.run_ctx.epoch_rearm_pending = false; self.reset_interrupt_countdown(); } pub(super) fn clear_epoch_deadline_internal(&mut self) { if self.epoch_interruption_enabled() { - self.interrupt_mode = InterruptMode::None; + self.run_ctx.interrupt_mode = InterruptMode::None; } - self.epoch_deadline = 0; - self.epoch_deadline_delta = 0; - self.epoch_rearm_pending = false; + self.run_ctx.epoch_deadline = 0; + self.run_ctx.epoch_deadline_delta = 0; + self.run_ctx.epoch_rearm_pending = false; self.reset_interrupt_countdown(); } @@ -118,29 +102,29 @@ impl Vm { } pub fn epoch_handle(&self) -> EpochHandle { - self.epoch_handle.clone() + self.run_ctx.epoch_handle.clone() } pub fn current_epoch(&self) -> u64 { - self.epoch_handle.current() + self.run_ctx.epoch_handle.current() } pub fn increment_epoch(&self) -> u64 { - self.epoch_handle.increment() + self.run_ctx.epoch_handle.increment() } pub fn increment_epoch_by(&self, delta: u64) -> u64 { - self.epoch_handle.increment_by(delta) + self.run_ctx.epoch_handle.increment_by(delta) } pub fn set_epoch_deadline(&mut self, ticks_beyond_current: u64) -> VmResult<()> { if self.fuel_metering_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Epoch)); } - self.interrupt_mode = InterruptMode::Epoch; - self.epoch_deadline = self.current_epoch().saturating_add(ticks_beyond_current); - self.epoch_deadline_delta = ticks_beyond_current; - self.epoch_rearm_pending = false; + self.run_ctx.interrupt_mode = InterruptMode::Epoch; + self.run_ctx.epoch_deadline = self.current_epoch().saturating_add(ticks_beyond_current); + self.run_ctx.epoch_deadline_delta = ticks_beyond_current; + self.run_ctx.epoch_rearm_pending = false; self.reset_interrupt_countdown(); Ok(()) } @@ -151,12 +135,12 @@ impl Vm { pub fn epoch_deadline(&self) -> Option { self.epoch_interruption_enabled() - .then_some(self.epoch_deadline) + .then_some(self.run_ctx.epoch_deadline) } pub fn epoch_deadline_delta(&self) -> Option { self.epoch_interruption_enabled() - .then_some(self.epoch_deadline_delta) + .then_some(self.run_ctx.epoch_deadline_delta) } pub fn set_epoch_check_interval(&mut self, interval: u32) -> VmResult<()> { @@ -166,7 +150,7 @@ impl Vm { if self.fuel_metering_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Epoch)); } - self.fuel_check_interval = interval; + self.run_ctx.fuel_check_interval = interval; self.reset_interrupt_countdown(); Ok(()) } @@ -179,31 +163,31 @@ impl Vm { EpochCheckpoint { deadline: self .epoch_interruption_enabled() - .then_some(self.epoch_deadline), - deadline_delta: self.epoch_deadline_delta, - rearm_pending: self.epoch_rearm_pending, + .then_some(self.run_ctx.epoch_deadline), + deadline_delta: self.run_ctx.epoch_deadline_delta, + rearm_pending: self.run_ctx.epoch_rearm_pending, check_interval: self.epoch_check_interval(), - ops_until_check: self.fuel_ops_until_check, + ops_until_check: self.run_ctx.fuel_ops_until_check, } } pub fn restore_epoch(&mut self, checkpoint: EpochCheckpoint) { self.clear_fuel_internal(); - self.interrupt_mode = if checkpoint.deadline.is_some() { + self.run_ctx.interrupt_mode = if checkpoint.deadline.is_some() { InterruptMode::Epoch } else { InterruptMode::None }; - self.epoch_deadline = checkpoint.deadline.unwrap_or(0); - self.epoch_deadline_delta = checkpoint.deadline_delta; - self.epoch_rearm_pending = checkpoint.rearm_pending; - self.fuel_check_interval = checkpoint.check_interval.max(1); - self.fuel_ops_until_check = checkpoint + self.run_ctx.epoch_deadline = checkpoint.deadline.unwrap_or(0); + self.run_ctx.epoch_deadline_delta = checkpoint.deadline_delta; + self.run_ctx.epoch_rearm_pending = checkpoint.rearm_pending; + self.run_ctx.fuel_check_interval = checkpoint.check_interval.max(1); + self.run_ctx.fuel_ops_until_check = checkpoint .ops_until_check - .clamp(1, self.fuel_check_interval); + .clamp(1, self.run_ctx.fuel_check_interval); } pub fn last_yield_reason(&self) -> Option { - self.last_yield_reason + self.instance.last_yield_reason } } diff --git a/src/vm/fuel.rs b/src/vm/fuel.rs index f7a3e9d1..4af4b090 100644 --- a/src/vm/fuel.rs +++ b/src/vm/fuel.rs @@ -19,61 +19,28 @@ impl FuelCheckpoint { impl Vm { pub(super) fn pending_fuel_debt(&self) -> u64 { - if !self.fuel_metering_enabled() { - return 0; - } - let executed_since_last_check = self - .fuel_check_interval - .saturating_sub(self.fuel_ops_until_check); - u64::from(executed_since_last_check) + self.run_ctx.pending_fuel_debt() } #[inline(always)] pub(in crate::vm) fn charge_fuel(&mut self, amount: u64) -> VmResult<()> { - if amount == 0 || !self.fuel_metering_enabled() { - return Ok(()); - } - - let remaining = self.fuel_remaining; - if remaining < amount { - return Err(VmError::OutOfFuel { - needed: amount, - remaining, - }); - } - self.fuel_remaining = remaining - amount; - Ok(()) + self.run_ctx.charge_fuel(amount) } #[inline(always)] pub(in crate::vm) fn charge_fuel_tick(&mut self) -> VmResult<()> { - if !self.fuel_metering_enabled() { - return Ok(()); - } - if self.fuel_ops_until_check > 1 { - self.fuel_ops_until_check -= 1; - return Ok(()); - } - - let amount = u64::from(self.fuel_check_interval); - self.charge_fuel(amount)?; - self.fuel_ops_until_check = self.fuel_check_interval; - Ok(()) + self.run_ctx.charge_fuel_tick() } pub(super) fn clear_fuel_internal(&mut self) { - if self.fuel_metering_enabled() { - self.interrupt_mode = InterruptMode::None; - } - self.fuel_remaining = 0; - self.reset_interrupt_countdown(); + self.run_ctx.clear_fuel_internal(); } pub fn set_fuel(&mut self, fuel: u64) { - self.clear_epoch_deadline_internal(); - self.interrupt_mode = InterruptMode::Fuel; - self.fuel_remaining = fuel; - self.reset_interrupt_countdown(); + self.run_ctx.clear_epoch_deadline_internal(); + self.run_ctx.interrupt_mode = InterruptMode::Fuel; + self.run_ctx.fuel_remaining = fuel; + self.run_ctx.reset_interrupt_countdown(); } pub fn clear_fuel(&mut self) { @@ -87,18 +54,21 @@ impl Vm { if self.epoch_interruption_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Fuel)); } - self.fuel_check_interval = interval; - self.reset_interrupt_countdown(); + self.run_ctx.fuel_check_interval = interval; + self.run_ctx.reset_interrupt_countdown(); Ok(()) } pub fn fuel_check_interval(&self) -> u32 { - self.fuel_check_interval + self.run_ctx.fuel_check_interval } pub fn get_fuel(&self) -> Option { - self.fuel_metering_enabled() - .then_some(self.fuel_remaining.saturating_sub(self.pending_fuel_debt())) + self.fuel_metering_enabled().then_some( + self.run_ctx + .fuel_remaining + .saturating_sub(self.pending_fuel_debt()), + ) } pub fn add_fuel(&mut self, fuel: u64) -> VmResult<()> { @@ -108,13 +78,14 @@ impl Vm { if self.epoch_interruption_enabled() { return Err(self.interruption_mode_conflict(InterruptMode::Fuel)); } - self.fuel_remaining = if self.fuel_metering_enabled() { - self.fuel_remaining + self.run_ctx.fuel_remaining = if self.fuel_metering_enabled() { + self.run_ctx + .fuel_remaining .checked_add(fuel) .ok_or(VmError::FuelOverflow)? } else { - self.interrupt_mode = InterruptMode::Fuel; - self.reset_interrupt_countdown(); + self.run_ctx.interrupt_mode = InterruptMode::Fuel; + self.run_ctx.reset_interrupt_countdown(); fuel }; Ok(()) @@ -140,9 +111,11 @@ impl Vm { pub fn fuel_checkpoint(&self) -> FuelCheckpoint { FuelCheckpoint { - remaining: self.fuel_metering_enabled().then_some(self.fuel_remaining), + remaining: self + .fuel_metering_enabled() + .then_some(self.run_ctx.fuel_remaining), check_interval: self.fuel_check_interval(), - ops_until_check: self.fuel_ops_until_check, + ops_until_check: self.run_ctx.fuel_ops_until_check, } } @@ -151,17 +124,17 @@ impl Vm { } pub fn restore_fuel(&mut self, checkpoint: FuelCheckpoint) { - self.clear_epoch_deadline_internal(); - self.interrupt_mode = if checkpoint.remaining.is_some() { + self.run_ctx.clear_epoch_deadline_internal(); + self.run_ctx.interrupt_mode = if checkpoint.remaining.is_some() { InterruptMode::Fuel } else { InterruptMode::None }; - self.fuel_remaining = checkpoint.remaining.unwrap_or(0); - self.fuel_check_interval = checkpoint.check_interval.max(1); - self.fuel_ops_until_check = checkpoint + self.run_ctx.fuel_remaining = checkpoint.remaining.unwrap_or(0); + self.run_ctx.fuel_check_interval = checkpoint.check_interval.max(1); + self.run_ctx.fuel_ops_until_check = checkpoint .ops_until_check - .clamp(1, self.fuel_check_interval); + .clamp(1, self.run_ctx.fuel_check_interval); } pub fn restore_checkpoint(&mut self, checkpoint: FuelCheckpoint) { diff --git a/src/vm/host.rs b/src/vm/host.rs index ecca479a..775a3b2a 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -1,8 +1,9 @@ +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, OnceLock, RwLock}; -use std::task::{Context, Poll, Wake, Waker}; use crate::builtins::BuiltinFunction; +use super::async_host::WaitingHostOp; use super::*; pub type HostOpId = u64; @@ -85,12 +86,6 @@ pub trait HostArgsFunction: Send { fn call(&mut self, args: &[Value]) -> VmResult; } -pub trait HostAsyncBridge: Send { - fn poll_op(&mut self, op_id: HostOpId, cx: &mut Context<'_>) -> Poll>; - - fn cancel_op(&mut self, _op_id: HostOpId) {} -} - pub type StaticHostFunction = fn(&mut Vm, &[Value]) -> VmResult; pub type StaticHostStackFunction = fn(&mut Vm, &[Value]) -> VmResult; pub type StaticHostArgsFunction = fn(&[Value]) -> VmResult; @@ -114,6 +109,7 @@ enum RegistryEntryKind { struct RegistryEntry { arity: u8, kind: RegistryEntryKind, + runtime_owned_pending: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -121,6 +117,16 @@ pub struct HostBindingPlan { import_signature: Vec, registry_slots: Vec, resolved_calls: Vec, + runtime_owned_pending_slots: Vec, + allowed_builtin_calls: Vec, + allow_default_builtin_capabilities: bool, + allowed_host_function_slots: Vec, + allow_default_host_capabilities: bool, + capability_profile: Arc, + capability_fingerprint: u64, + registry_state: Arc<()>, + registry_generation_token: Arc<()>, + registry_generation: u64, } #[derive(Clone)] @@ -128,6 +134,13 @@ pub struct HostFunctionRegistry { entries: Arc>, by_name: Arc>, plan_cache: Arc, Arc>>>, + allowed_builtin_calls: Arc>, + allow_default_builtin_capabilities: bool, + allow_default_host_capabilities: bool, + capability_profile: Arc, + registry_state: Arc<()>, + registry_generation_token: Arc<()>, + registry_generation: Arc, } impl Default for HostFunctionRegistry { @@ -137,30 +150,103 @@ impl Default for HostFunctionRegistry { } impl HostFunctionRegistry { - fn empty() -> Self { + pub fn empty() -> Self { Self { entries: Arc::new(Vec::new()), by_name: Arc::new(HashMap::new()), plan_cache: Arc::new(RwLock::new(HashMap::new())), + allowed_builtin_calls: Arc::new(Vec::new()), + allow_default_builtin_capabilities: true, + allow_default_host_capabilities: true, + capability_profile: Arc::new(CapabilityProfile::allow_all()), + registry_state: Arc::new(()), + registry_generation_token: Arc::new(()), + registry_generation: Arc::new(AtomicU64::new(0)), } } pub fn new() -> Self { static DEFAULT_REGISTRY: OnceLock = OnceLock::new(); - DEFAULT_REGISTRY + let mut registry = DEFAULT_REGISTRY .get_or_init(|| { let mut registry = Self::empty(); crate::builtins::runtime::register_default_host_functions(&mut registry); + registry.allow_default_builtin_capabilities = true; + registry.allow_default_host_capabilities = true; registry }) - .clone() + .clone(); + registry.plan_cache = Arc::new(RwLock::new(HashMap::new())); + registry.capability_profile = Arc::new(CapabilityProfile::allow_all()); + registry.registry_state = Arc::new(()); + registry.registry_generation_token = Arc::new(()); + registry.registry_generation = Arc::new(AtomicU64::new(0)); + registry + } + + /// Returns the standard host registry with every registered host function present but + /// requiring an explicit capability grant before execution. + pub fn restricted() -> Self { + let mut registry = Self::new(); + registry.allow_default_builtin_capabilities = false; + registry.allow_default_host_capabilities = false; + registry.capability_profile = Arc::new(CapabilityProfile::deny_all()); + registry.registry_state = Arc::new(()); + registry.registry_generation_token = Arc::new(()); + registry.registry_generation = Arc::new(AtomicU64::new(0)); + registry.invalidate_plan_cache(); + registry + } + + /// Replaces the registry's immutable capability profile. + pub fn set_capability_profile(&mut self, profile: CapabilityProfile) { + self.allowed_builtin_calls = Arc::new(profile.allowed_builtin_calls().to_vec()); + self.allow_default_builtin_capabilities = profile.allows_all_builtins(); + self.allow_default_host_capabilities = profile.allows_all_host_imports(); + self.capability_profile = Arc::new(profile); + self.invalidate_plan_cache(); + } + + /// Explicitly permits a namespaced builtin when this registry is used as a capability plan. + pub fn allow_builtin(&mut self, name: impl AsRef) -> VmResult<()> { + let name = name.as_ref(); + if self.by_name.contains_key(name) { + self.capability_profile = Arc::new(self.capability_profile.with_host_import(name)); + self.invalidate_plan_cache(); + return Ok(()); + } + let builtin = BuiltinFunction::from_namespaced_name(name) + .ok_or_else(|| VmError::HostError(format!("unknown namespaced builtin '{name}'")))?; + let calls = Arc::make_mut(&mut self.allowed_builtin_calls); + if !calls.contains(&builtin.call_index()) { + calls.push(builtin.call_index()); + calls.sort_unstable(); + } + self.capability_profile = Arc::new(self.capability_profile.with_builtin(builtin)); + self.invalidate_plan_cache(); + Ok(()) } fn invalidate_plan_cache(&mut self) { + self.registry_state = Arc::new(()); + self.registry_generation.fetch_add(1, Ordering::Relaxed); self.plan_cache = Arc::new(RwLock::new(HashMap::new())); } + #[allow(dead_code)] + pub(crate) fn mark_runtime_owned_pending(&mut self, name: &str) { + let slot = self + .by_name + .get(name) + .copied() + .expect("generated runtime host function should be registered"); + let entry = Arc::make_mut(&mut self.entries) + .get_mut(slot as usize) + .expect("generated runtime host function slot should exist"); + entry.runtime_owned_pending = true; + } + pub fn register(&mut self, name: impl Into, arity: u8, factory: F) where F: Fn() -> Box + Send + Sync + 'static, @@ -170,6 +256,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::Factory(Arc::new(factory)); self.invalidate_plan_cache(); return; @@ -179,6 +266,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::Factory(Arc::new(factory)), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -196,6 +284,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::Static(function); self.invalidate_plan_cache(); return; @@ -205,6 +294,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::Static(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -220,6 +310,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::StackFactory(Arc::new(factory)); self.invalidate_plan_cache(); return; @@ -229,6 +320,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::StackFactory(Arc::new(factory)), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -246,6 +338,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::StackStatic(function); self.invalidate_plan_cache(); return; @@ -255,6 +348,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::StackStatic(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -270,6 +364,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::ArgsFactory(Arc::new(factory)); self.invalidate_plan_cache(); return; @@ -279,6 +374,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::ArgsFactory(Arc::new(factory)), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -296,6 +392,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::ArgsStatic(function); self.invalidate_plan_cache(); return; @@ -305,6 +402,7 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::ArgsStatic(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); @@ -328,6 +426,7 @@ impl HostFunctionRegistry { && let Some(entry) = Arc::make_mut(&mut self.entries).get_mut(slot as usize) { entry.arity = arity; + entry.runtime_owned_pending = false; entry.kind = RegistryEntryKind::ArgsStaticNonYielding(function); self.invalidate_plan_cache(); return; @@ -337,13 +436,58 @@ impl HostFunctionRegistry { let slot = entries.len() as u16; entries.push(RegistryEntry { arity, + runtime_owned_pending: false, kind: RegistryEntryKind::ArgsStaticNonYielding(function), }); Arc::make_mut(&mut self.by_name).insert(name, slot); self.invalidate_plan_cache(); } + fn validate_builtin_capability(&self, call_index: u16) -> VmResult<()> { + if let Some(builtin) = BuiltinFunction::from_call_index(call_index) + && builtin.requires_explicit_host_capability() + && !self.allowed_builtin_calls.contains(&call_index) + { + return Err(VmError::HostError(format!( + "capability profile does not allow builtin '{}'", + builtin.name() + ))); + } + Ok(()) + } + + fn validate_program_capabilities(&self, program: &Program) -> VmResult<()> { + if self.allow_default_builtin_capabilities { + return Ok(()); + } + let mut ip = 0usize; + while let Some(&raw_opcode) = program.code.get(ip) { + let opcode = + OpCode::try_from(raw_opcode).map_err(|_| VmError::InvalidOpcode(raw_opcode))?; + let operand_end = ip + .checked_add(1 + opcode.operand_len()) + .ok_or(VmError::BytecodeBounds)?; + if operand_end > program.code.len() { + return Err(VmError::BytecodeBounds); + } + if opcode == OpCode::Call { + let bytes: [u8; 2] = program.code[ip + 1..ip + 3] + .try_into() + .map_err(|_| VmError::BytecodeBounds)?; + self.validate_builtin_capability(u16::from_le_bytes(bytes))?; + } + ip = operand_end; + } + for prototype in &program.callable_prototypes { + if let CallableTarget::HostImport(call_index) = prototype.target { + self.validate_builtin_capability(call_index)?; + } + } + Ok(()) + } + pub fn bind_vm_cached(&self, vm: &mut Vm) -> VmResult<()> { + self.validate_program_capabilities(&vm.program)?; let plan = self.prepare_shared_plan(&vm.program.imports)?; self.bind_vm_with_plan(vm, &plan) } @@ -356,6 +500,17 @@ impl HostFunctionRegistry { self.plan_for_imports(imports) } + fn plan_matches_current(&self, plan: &HostBindingPlan) -> bool { + self.capability_profile.fingerprint() == plan.capability_fingerprint + && self.capability_profile.as_ref() == plan.capability_profile.as_ref() + && Arc::ptr_eq(&self.registry_state, &plan.registry_state) + && Arc::ptr_eq( + &self.registry_generation_token, + &plan.registry_generation_token, + ) + && self.registry_generation.load(Ordering::Relaxed) == plan.registry_generation + } + fn plan_for_imports(&self, imports: &[HostImport]) -> VmResult> { if let Some(plan) = self .plan_cache @@ -363,6 +518,7 @@ impl HostFunctionRegistry { .expect("host binding plan cache read lock should not be poisoned") .get(imports) .cloned() + && self.plan_matches_current(&plan) { return Ok(plan); } @@ -381,6 +537,14 @@ impl HostFunctionRegistry { .entries .get(registry_slot as usize) .ok_or(VmError::InvalidCall(registry_slot))?; + if !self.allow_default_host_capabilities + && !self.capability_profile.allows_host_import(&import.name) + { + return Err(VmError::HostError(format!( + "capability profile does not allow host import '{}'", + import.name + ))); + } if entry.arity != import.arity { return Err(VmError::InvalidCallArity { import: import.name.clone(), @@ -400,32 +564,86 @@ impl HostFunctionRegistry { resolved_calls.push(vm_slot); } + let mut allowed_host_function_slots = imports + .iter() + .zip(resolved_calls.iter().copied()) + .filter_map(|(import, vm_slot)| { + self.capability_profile + .allows_host_import(&import.name) + .then_some(vm_slot) + }) + .collect::>(); + allowed_host_function_slots.sort_unstable(); + allowed_host_function_slots.dedup(); + let runtime_owned_pending_slots = registry_slots + .iter() + .enumerate() + .filter_map(|(vm_slot, registry_slot)| { + self.entries + .get(*registry_slot as usize) + .filter(|entry| entry.runtime_owned_pending) + .map(|_| vm_slot as u16) + }) + .collect(); let import_key = imports.to_vec(); let computed = Arc::new(HostBindingPlan { import_signature: import_key.clone(), registry_slots, resolved_calls, + runtime_owned_pending_slots, + allowed_builtin_calls: self.allowed_builtin_calls.as_ref().clone(), + allow_default_builtin_capabilities: self.allow_default_builtin_capabilities, + allowed_host_function_slots, + allow_default_host_capabilities: self.allow_default_host_capabilities, + capability_profile: Arc::clone(&self.capability_profile), + capability_fingerprint: self.capability_profile.fingerprint(), + registry_state: Arc::clone(&self.registry_state), + registry_generation_token: Arc::clone(&self.registry_generation_token), + registry_generation: self.registry_generation.load(Ordering::Relaxed), }); let mut cache = self .plan_cache .write() .expect("host binding plan cache write lock should not be poisoned"); - Ok(cache.entry(import_key).or_insert_with(|| computed).clone()) + cache.insert(import_key, Arc::clone(&computed)); + Ok(computed) } pub fn bind_vm_with_plan(&self, vm: &mut Vm, plan: &HostBindingPlan) -> VmResult<()> { + self.validate_program_capabilities(&vm.program)?; if vm.program.imports != plan.import_signature { return Err(VmError::HostError( "host binding plan does not match vm import signature".to_string(), )); } - if !vm.host_functions.is_empty() || !vm.host_function_symbols.is_empty() { + if self.capability_profile.fingerprint() != plan.capability_fingerprint + || self.capability_profile.as_ref() != plan.capability_profile.as_ref() + { + return Err(VmError::HostError( + "host binding plan belongs to a different capability profile".to_string(), + )); + } + if !Arc::ptr_eq(&self.registry_state, &plan.registry_state) { + return Err(VmError::HostError( + "host binding plan belongs to a different registry state".to_string(), + )); + } + if !Arc::ptr_eq( + &self.registry_generation_token, + &plan.registry_generation_token, + ) || self.registry_generation.load(Ordering::Relaxed) != plan.registry_generation + { + return Err(VmError::HostError( + "host binding plan is stale for this registry".to_string(), + )); + } + if !vm.host.host_functions.is_empty() || !vm.host.host_function_symbols.is_empty() { return Err(VmError::HostError( "host binding cache requires an unbound vm".to_string(), )); } - vm.host_functions.reserve(plan.registry_slots.len()); + vm.host.host_functions.reserve(plan.registry_slots.len()); for ®istry_slot in &plan.registry_slots { let entry = self .entries @@ -455,6 +673,13 @@ impl HostFunctionRegistry { } } } + vm.set_default_host_fallback_enabled(false); + vm.host.allowed_builtin_calls = plan.allowed_builtin_calls.clone(); + vm.host.allow_default_builtin_capabilities = plan.allow_default_builtin_capabilities; + vm.host.allowed_host_function_slots = plan.allowed_host_function_slots.clone(); + vm.host.allow_default_host_capabilities = plan.allow_default_host_capabilities; + vm.host.runtime_owned_pending_host_slots = + plan.runtime_owned_pending_slots.iter().copied().collect(); vm.install_resolved_calls(plan.resolved_calls.clone())?; Ok(()) } @@ -530,28 +755,6 @@ pub(crate) fn validate_non_yielding_host_value( Err(VmError::TypeMismatch(expected)) } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) struct WaitingHostOp { - pub(super) op_id: HostOpId, - pub(super) source: WaitingHostOpSource, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum WaitingHostOpSource { - HostBridge, - BuiltinIo, -} - -struct NoopWake; - -impl Wake for NoopWake { - fn wake(self: Arc) {} -} - -fn noop_waker() -> Waker { - Waker::from(Arc::new(NoopWake)) -} - #[inline] fn builtin_for_binding_name(name: &str) -> Option { if !name.contains("::") { @@ -562,48 +765,56 @@ fn builtin_for_binding_name(name: &str) -> Option { impl Vm { pub fn register_function(&mut self, function: Box) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions.push(VmHostFunction::Dynamic(function)); - self.resolved_calls_dirty = true; + let index = self.host.host_functions.len() as u16; + self.host + .host_functions + .push(VmHostFunction::Dynamic(function)); + self.host.resolved_calls_dirty = true; index } pub fn register_static_function(&mut self, function: StaticHostFunction) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions.push(VmHostFunction::Static(function)); - self.resolved_calls_dirty = true; + let index = self.host.host_functions.len() as u16; + self.host + .host_functions + .push(VmHostFunction::Static(function)); + self.host.resolved_calls_dirty = true; index } pub fn register_stack_function(&mut self, function: Box) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::StackDynamic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } pub fn register_static_stack_function(&mut self, function: StaticHostStackFunction) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::StackStatic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } pub fn register_args_function(&mut self, function: Box) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::ArgsDynamic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } pub fn register_static_args_function(&mut self, function: StaticHostArgsFunction) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::ArgsStatic(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } @@ -617,49 +828,80 @@ impl Vm { &mut self, function: StaticHostArgsFunction, ) -> u16 { - let index = self.host_functions.len() as u16; - self.host_functions + let index = self.host.host_functions.len() as u16; + self.host + .host_functions .push(VmHostFunction::ArgsStaticNonYielding(function)); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; index } + fn clear_runtime_owned_pending_binding(&mut self, name: &str) { + let slot = builtin_for_binding_name(name) + .and_then(|builtin| { + self.host + .builtin_overrides + .get(&builtin.call_index()) + .copied() + }) + .or_else(|| self.host.host_function_symbols.get(name).copied()); + if let Some(slot) = slot { + self.host.runtime_owned_pending_host_slots.remove(&slot); + } + } + + #[allow(dead_code)] + pub(crate) fn mark_runtime_owned_pending_binding(&mut self, name: &str) { + let slot = builtin_for_binding_name(name) + .and_then(|builtin| { + self.host + .builtin_overrides + .get(&builtin.call_index()) + .copied() + }) + .or_else(|| self.host.host_function_symbols.get(name).copied()) + .expect("generated runtime host binding should exist"); + self.host.runtime_owned_pending_host_slots.insert(slot); + } + pub fn bind_function(&mut self, name: impl Into, function: Box) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot(builtin.call_index(), VmHostFunction::Dynamic(function)); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::Dynamic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_static_function(&mut self, name: impl Into, function: StaticHostFunction) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot(builtin.call_index(), VmHostFunction::Static(function)); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::Static(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_stack_function( @@ -668,17 +910,18 @@ impl Vm { function: Box, ) { let name = name.into(); - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + self.clear_runtime_owned_pending_binding(&name); + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::StackDynamic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_stack_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_static_stack_function( @@ -687,6 +930,7 @@ impl Vm { function: StaticHostStackFunction, ) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot( builtin.call_index(), @@ -694,17 +938,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::StackStatic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_stack_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_args_function( @@ -713,6 +957,7 @@ impl Vm { function: Box, ) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot( builtin.call_index(), @@ -720,17 +965,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::ArgsDynamic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_args_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_static_args_function( @@ -739,6 +984,7 @@ impl Vm { function: StaticHostArgsFunction, ) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot( builtin.call_index(), @@ -746,17 +992,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::ArgsStatic(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_args_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } /// Binds a static args-only host function that always returns one value synchronously. @@ -771,6 +1017,7 @@ impl Vm { function: StaticHostArgsFunction, ) { let name = name.into(); + self.clear_runtime_owned_pending_binding(&name); if let Some(builtin) = builtin_for_binding_name(&name) { self.bind_builtin_overrideslot( builtin.call_index(), @@ -778,17 +1025,17 @@ impl Vm { ); return; } - if let Some(&index) = self.host_function_symbols.get(&name) - && let Some(slot) = self.host_functions.get_mut(index as usize) + if let Some(&index) = self.host.host_function_symbols.get(&name) + && let Some(slot) = self.host.host_functions.get_mut(index as usize) { *slot = VmHostFunction::ArgsStaticNonYielding(function); - self.resolved_calls_dirty = true; + self.host.resolved_calls_dirty = true; return; } let index = self.register_static_non_yielding_args_function(function); - self.host_function_symbols.insert(name, index); - self.resolved_calls_dirty = true; + self.host.host_function_symbols.insert(name, index); + self.host.resolved_calls_dirty = true; } pub fn bind_builtin_override( @@ -800,6 +1047,7 @@ impl Vm { let builtin = BuiltinFunction::from_namespaced_name(&name).ok_or_else(|| { VmError::HostError(format!("unknown namespaced builtin override '{name}'")) })?; + self.clear_runtime_owned_pending_binding(&name); self.bind_builtin_overrideslot(builtin.call_index(), VmHostFunction::Dynamic(function)); Ok(()) } @@ -813,46 +1061,52 @@ impl Vm { let builtin = BuiltinFunction::from_namespaced_name(&name).ok_or_else(|| { VmError::HostError(format!("unknown namespaced builtin override '{name}'")) })?; + self.clear_runtime_owned_pending_binding(&name); self.bind_builtin_overrideslot(builtin.call_index(), VmHostFunction::Static(function)); Ok(()) } fn bind_builtin_overrideslot(&mut self, builtin_call_index: u16, function: VmHostFunction) { - if let Some(&host_slot) = self.builtin_overrides.get(&builtin_call_index) - && let Some(slot) = self.host_functions.get_mut(host_slot as usize) + if let Some(&host_slot) = self.host.builtin_overrides.get(&builtin_call_index) + && let Some(slot) = self.host.host_functions.get_mut(host_slot as usize) { *slot = function; return; } - let host_slot = self.host_functions.len() as u16; - self.host_functions.push(function); - self.builtin_overrides.insert(builtin_call_index, host_slot); - } - - pub fn set_async_bridge(&mut self, bridge: Box) { - self.cancel_waiting_host_op(); - self.async_bridge = Some(bridge); - } - - pub fn clear_async_bridge(&mut self) { - self.cancel_waiting_host_op(); - self.async_bridge = None; + let host_slot = self.host.host_functions.len() as u16; + self.host.host_functions.push(function); + self.host + .builtin_overrides + .insert(builtin_call_index, host_slot); } pub fn set_runtime_print_sink(&mut self, sink: F) where F: FnMut(String) + Send + 'static, { - self.runtime_print_sink = Some(Box::new(sink)); + self.host.runtime_print_sink = Some(Box::new(sink)); } pub fn clear_runtime_print_sink(&mut self) { - self.runtime_print_sink = None; + self.host.runtime_print_sink = None; + } + + /// Enables or disables implicit binding of built-in host functions. + /// + /// Disabling this makes the VM use only explicitly registered host functions. The default + /// remains enabled for backwards compatibility until a registry is bound. + pub fn set_default_host_fallback_enabled(&mut self, enabled: bool) { + self.host.allow_default_host_fallback = enabled; + self.host.resolved_calls_dirty = true; + } + + pub fn default_host_fallback_enabled(&self) -> bool { + self.host.allow_default_host_fallback } pub(crate) fn write_runtime_print(&mut self, rendered: String) -> VmResult<()> { - let Some(sink) = self.runtime_print_sink.as_mut() else { + let Some(sink) = self.host.runtime_print_sink.as_mut() else { return Err(VmError::HostError( "runtime print sink is not configured".to_string(), )); @@ -861,103 +1115,6 @@ impl Vm { Ok(()) } - pub fn allocate_host_op_id(&mut self) -> HostOpId { - let op_id = self.next_host_op_id; - self.next_host_op_id = self.next_host_op_id.wrapping_add(1).max(1); - op_id - } - - pub fn waiting_host_op_id(&self) -> Option { - self.waiting_host_op.map(|op| op.op_id) - } - - pub(super) fn cancel_waiting_host_op(&mut self) { - let Some(waiting) = self.waiting_host_op.take() else { - return; - }; - match waiting.source { - WaitingHostOpSource::HostBridge => { - if let Some(bridge) = self.async_bridge.as_mut() { - bridge.cancel_op(waiting.op_id); - } - } - WaitingHostOpSource::BuiltinIo => { - crate::builtins::runtime::cancel_builtin_io_op(self, waiting.op_id); - } - } - } - - pub fn complete_host_op( - &mut self, - op_id: HostOpId, - values: impl Into, - ) -> VmResult<()> { - self.complete_waiting_host_op(op_id, values.into()) - } - - pub fn poll_waiting_host_op(&mut self, cx: &mut Context<'_>) -> Poll> { - let Some(waiting) = self.waiting_host_op else { - return Poll::Ready(Ok(())); - }; - - let poll_result = match waiting.source { - WaitingHostOpSource::HostBridge => { - let bridge_ptr = match self.async_bridge.as_mut() { - Some(bridge) => bridge.as_mut() as *mut dyn HostAsyncBridge, - None => { - return Poll::Ready(Err(VmError::HostError(format!( - "vm waiting on host op {} without an async bridge", - waiting.op_id - )))); - } - }; - - unsafe { (&mut *bridge_ptr).poll_op(waiting.op_id, cx) } - } - WaitingHostOpSource::BuiltinIo => { - crate::builtins::runtime::poll_builtin_io_op(self, waiting.op_id, cx) - } - }; - - match poll_result { - Poll::Pending => Poll::Pending, - Poll::Ready(Ok(values)) => { - self.complete_waiting_host_op(waiting.op_id, values)?; - Poll::Ready(Ok(())) - } - Poll::Ready(Err(err)) => { - self.waiting_host_op = None; - Poll::Ready(Err(err)) - } - } - } - - pub async fn await_waiting_host_op(&mut self) -> VmResult<()> { - std::future::poll_fn(|cx| self.poll_waiting_host_op(cx)).await - } - - pub fn wait_for_host_op_blocking(&mut self) -> VmResult<()> { - let waker = noop_waker(); - let mut cx = Context::from_waker(&waker); - loop { - match self.poll_waiting_host_op(&mut cx) { - Poll::Ready(result) => return result, - Poll::Pending => { - #[cfg(not(target_arch = "wasm32"))] - { - std::thread::sleep(std::time::Duration::from_millis(1)); - } - #[cfg(target_arch = "wasm32")] - { - return Err(VmError::HostError( - "blocking host-op wait is unsupported on wasm32 runtime".to_string(), - )); - } - } - } - } - } - pub(super) fn execute_host_call( &mut self, index: u16, @@ -966,6 +1123,12 @@ impl Vm { ) -> VmResult { let argc = argc_u8 as usize; if let Some(builtin) = BuiltinFunction::from_call_index(index) { + if builtin.requires_explicit_host_capability() + && !self.host.allow_default_builtin_capabilities + && !self.host.allowed_builtin_calls.contains(&index) + { + return Err(VmError::UnboundImport(builtin.name().to_string())); + } if !builtin.accepts_arity(argc_u8) { return Err(VmError::InvalidCallArity { import: builtin.name().to_string(), @@ -973,7 +1136,7 @@ impl Vm { got: argc_u8, }); } - if self.builtin_overrides.contains_key(&index) { + if self.host.builtin_overrides.contains_key(&index) { return self.execute_builtin_override_call(index, argc_u8, call_ip); } if let Some(outcome) = @@ -994,13 +1157,28 @@ impl Vm { .get(usize::from(index)) .map(|import| import.return_type); let resolved_index = self.resolve_call_target(index, argc_u8)?; - if let Some(function) = - self.host_functions - .get(resolved_index as usize) - .and_then(|function| match function { - VmHostFunction::ArgsStaticNonYielding(function) => Some(*function), - _ => None, - }) + if !self.host.allow_default_host_capabilities + && !self + .host + .allowed_host_function_slots + .contains(&resolved_index) + { + let import_name = self + .program + .imports + .get(usize::from(index)) + .map(|import| import.name.clone()) + .unwrap_or_else(|| format!("host slot {resolved_index}")); + return Err(VmError::UnboundImport(import_name)); + } + if let Some(function) = self + .host + .host_functions + .get(resolved_index as usize) + .and_then(|function| match function { + VmHostFunction::ArgsStaticNonYielding(function) => Some(*function), + _ => None, + }) { return self.execute_static_non_yielding_args_host_function( function, @@ -1029,6 +1207,7 @@ impl Vm { call_ip: usize, ) -> VmResult { let resolved_index = self + .host .builtin_overrides .get(&builtin_call_index) .copied() @@ -1054,32 +1233,46 @@ impl Vm { call_ip: usize, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; // Builtin dispatch reads arguments from the current stack tail while mutating the VM. - // The builtin runtime must not mutate `self.stack` until this borrowed slice is consumed. + // The builtin runtime must not mutate `self.instance.stack` until this borrowed slice is consumed. let outcome = unsafe { - let args = std::slice::from_raw_parts_mut(self.stack.as_mut_ptr().add(arg_start), argc); + let args = std::slice::from_raw_parts_mut( + self.instance.stack.as_mut_ptr().add(arg_start), + argc, + ); crate::builtins::runtime::execute_builtin_call(self, builtin, args) }?; match outcome { crate::builtins::runtime::BuiltinCallOutcome::Return(values) => { - self.stack.truncate(arg_start); - values.push_onto_stack(&mut self.stack); + self.instance.stack.truncate(arg_start); + values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) } crate::builtins::runtime::BuiltinCallOutcome::Halt => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); Ok(HostCallExecOutcome::Halted) } crate::builtins::runtime::BuiltinCallOutcome::Pending(op_id) => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_host_op(op_id, WaitingHostOpSource::BuiltinIo)?; - self.ip = resume_ip; + if self.host.submitted_host_ops.contains(&op_id) { + if let Err(error) = self.set_waiting_host_op(op_id) { + self.host.submitted_host_ops.remove(&op_id); + if let Some(bridge) = self.host.async_bridge.as_mut() { + bridge.cancel_op(op_id); + } + return Err(error); + } + } else { + self.set_waiting_registered_op(op_id)?; + } + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1092,13 +1285,14 @@ impl Vm { call_ip: usize, ) -> VmResult> { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; let (lhs, rhs) = self.operand_value_types(call_ip); let result = { - let args = &self.stack[arg_start..]; + let args = &self.instance.stack[arg_start..]; match builtin { BuiltinFunction::Len => match (lhs, args) { ( @@ -1166,8 +1360,8 @@ impl Vm { let Some(value) = result else { return Ok(None); }; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); self.record_typed_builtin_fast_path(); Ok(Some(HostCallExecOutcome::Returned)) } @@ -1178,12 +1372,13 @@ impl Vm { argc: usize, ) -> VmResult> { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; let result = { - let args = &self.stack[arg_start..]; + let args = &self.instance.stack[arg_start..]; match (builtin, args) { (BuiltinFunction::Len, [value]) => Self::fast_path_len_result(value), (BuiltinFunction::Get, [container, key]) => { @@ -1198,8 +1393,8 @@ impl Vm { let Some(value) = result else { return Ok(None); }; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); self.record_projection_fast_path(); Ok(Some(HostCallExecOutcome::Returned)) } @@ -1460,14 +1655,16 @@ impl Vm { call_ip: usize, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - let mut saved_stack = std::mem::take(&mut self.stack); - self.call_depth += 1; + let mut saved_stack = std::mem::take(&mut self.instance.stack); + self.instance.call_depth += 1; let function_ptr = - self.host_functions + self.host + .host_functions .get_mut(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))? as *mut VmHostFunction; let outcome = unsafe { @@ -1482,15 +1679,15 @@ impl Vm { | VmHostFunction::ArgsStaticNonYielding(_) => unreachable!(), } }; - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth = self.instance.call_depth.saturating_sub(1); - let mut host_stack = std::mem::take(&mut self.stack); + let mut host_stack = std::mem::take(&mut self.instance.stack); let outcome = match outcome { Ok(outcome) => outcome, Err(err) => { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; return Err(err); } }; @@ -1500,28 +1697,28 @@ impl Vm { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); values.push_onto_stack(&mut saved_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; Ok(HostCallExecOutcome::Returned) } CallOutcome::Halt => { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; Ok(HostCallExecOutcome::Halted) } CallOutcome::Yield => { saved_stack.append(&mut host_stack); - self.stack = saved_stack; - self.ip = call_ip; + self.instance.stack = saved_stack; + self.instance.ip = call_ip; Ok(HostCallExecOutcome::Yielded) } CallOutcome::Pending(op_id) => { saved_stack.truncate(arg_start); saved_stack.append(&mut host_stack); - self.stack = saved_stack; + self.instance.stack = saved_stack; let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_host_op(op_id, WaitingHostOpSource::HostBridge)?; - self.ip = resume_ip; + self.set_waiting_bound_host_op(resolved_index, op_id)?; + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1529,6 +1726,7 @@ impl Vm { fn bound_host_function_uses_args_slice(&self, resolved_index: u16) -> VmResult { let function = self + .host .host_functions .get(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))?; @@ -1542,6 +1740,7 @@ impl Vm { fn bound_host_function_uses_stack_borrow(&self, resolved_index: u16) -> VmResult { let function = self + .host .host_functions .get(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))?; @@ -1559,17 +1758,18 @@ impl Vm { expected_return_type: Option, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - self.call_depth += 1; - let outcome = function(&self.stack[arg_start..]); - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth += 1; + let outcome = function(&self.instance.stack[arg_start..]); + self.instance.call_depth = self.instance.call_depth.saturating_sub(1); let value = require_non_yielding_host_value(outcome?)?; let value = validate_non_yielding_host_value(value, expected_return_type)?; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); Ok(HostCallExecOutcome::Returned) } @@ -1581,14 +1781,16 @@ impl Vm { expected_return_type: Option, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - self.call_depth += 1; + self.instance.call_depth += 1; let outcome = { - let args = &self.stack[arg_start..]; + let args = &self.instance.stack[arg_start..]; let function = self + .host .host_functions .get_mut(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))?; @@ -1602,36 +1804,36 @@ impl Vm { | VmHostFunction::StackStatic(_) => unreachable!(), } }; - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth = self.instance.call_depth.saturating_sub(1); let (outcome, non_yielding) = outcome; let outcome = outcome?; if non_yielding { let value = require_non_yielding_host_value(outcome)?; let value = validate_non_yielding_host_value(value, expected_return_type)?; - self.stack.truncate(arg_start); - self.stack.push(value); + self.instance.stack.truncate(arg_start); + self.instance.stack.push(value); return Ok(HostCallExecOutcome::Returned); } match outcome { CallOutcome::Return(values) => { - self.stack.truncate(arg_start); - values.push_onto_stack(&mut self.stack); + self.instance.stack.truncate(arg_start); + values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) } CallOutcome::Halt => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); Ok(HostCallExecOutcome::Halted) } CallOutcome::Yield => { - self.ip = call_ip; + self.instance.ip = call_ip; Ok(HostCallExecOutcome::Yielded) } CallOutcome::Pending(op_id) => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_host_op(op_id, WaitingHostOpSource::HostBridge)?; - self.ip = resume_ip; + self.set_waiting_bound_host_op(resolved_index, op_id)?; + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1644,20 +1846,23 @@ impl Vm { call_ip: usize, ) -> VmResult { let arg_start = self + .instance .stack .len() .checked_sub(argc) .ok_or(VmError::StackUnderflow)?; - self.call_depth += 1; + self.instance.call_depth += 1; let function_ptr = - self.host_functions + self.host + .host_functions .get_mut(resolved_index as usize) .ok_or(VmError::InvalidCall(resolved_index))? as *mut VmHostFunction; // Stack-borrowed host functions opt into the same raw stack-tail borrowing model used - // by builtin dispatch. They must not re-enter the VM or otherwise mutate `self.stack` + // by builtin dispatch. They must not re-enter the VM or otherwise mutate `self.instance.stack` // while the borrowed slice is alive. let outcome = unsafe { - let args = std::slice::from_raw_parts(self.stack.as_ptr().add(arg_start), argc); + let args = + std::slice::from_raw_parts(self.instance.stack.as_ptr().add(arg_start), argc); match &mut *function_ptr { VmHostFunction::StackDynamic(function) => function.call(self, args), VmHostFunction::StackStatic(function) => function(self, args), @@ -1668,28 +1873,28 @@ impl Vm { | VmHostFunction::ArgsStaticNonYielding(_) => unreachable!(), } }; - self.call_depth = self.call_depth.saturating_sub(1); + self.instance.call_depth = self.instance.call_depth.saturating_sub(1); let outcome = outcome?; match outcome { CallOutcome::Return(values) => { - self.stack.truncate(arg_start); - values.push_onto_stack(&mut self.stack); + self.instance.stack.truncate(arg_start); + values.push_onto_stack(&mut self.instance.stack); Ok(HostCallExecOutcome::Returned) } CallOutcome::Halt => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); Ok(HostCallExecOutcome::Halted) } CallOutcome::Yield => { - self.ip = call_ip; + self.instance.ip = call_ip; Ok(HostCallExecOutcome::Yielded) } CallOutcome::Pending(op_id) => { - self.stack.truncate(arg_start); + self.instance.stack.truncate(arg_start); let resume_ip = self.call_resume_ip(call_ip)?; - self.set_waiting_host_op(op_id, WaitingHostOpSource::HostBridge)?; - self.ip = resume_ip; + self.set_waiting_bound_host_op(resolved_index, op_id)?; + self.instance.ip = resume_ip; Ok(HostCallExecOutcome::Pending(op_id)) } } @@ -1715,12 +1920,91 @@ impl Vm { Ok(resume_ip) } - pub(super) fn set_waiting_host_op( - &mut self, - op_id: HostOpId, - source: WaitingHostOpSource, - ) -> VmResult<()> { - if let Some(active) = self.waiting_host_op + fn set_waiting_registered_op(&mut self, op_id: HostOpId) -> VmResult<()> { + let operation_id = crate::builtins::runtime::cancellation::OperationId::from_raw(op_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + let operation = self + .host + .runtime_operations + .get(operation_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + if operation.owner() == crate::builtins::runtime::cancellation::OperationOwner::HostBridge { + return Err(VmError::HostError(format!( + "builtin pending operation {op_id} is owned by the host bridge", + ))); + } + self.set_waiting_operation(op_id) + } + + fn set_waiting_bound_host_op(&mut self, resolved_index: u16, op_id: HostOpId) -> VmResult<()> { + if self + .host + .runtime_owned_pending_host_slots + .contains(&resolved_index) + { + self.set_waiting_registered_op(op_id) + } else { + self.set_waiting_host_op(op_id) + } + } + + pub(super) fn set_waiting_host_op(&mut self, op_id: HostOpId) -> VmResult<()> { + let result = (|| { + let operation_id = crate::builtins::runtime::cancellation::OperationId::from_raw(op_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + self.host + .runtime_operations + .retire_external_id(operation_id) + .map_err(|error| VmError::HostError(error.to_string()))?; + match self.host.runtime_operations.get(operation_id) { + Ok(operation) + if operation.owner() + == crate::builtins::runtime::cancellation::OperationOwner::HostBridge => {} + Ok(_) => { + return Err(VmError::HostError(format!( + "host bridge operation id {op_id} collides with a runtime-owned operation", + ))); + } + Err(_) => { + self.host + .runtime_operations + .register_retired_external( + operation_id, + crate::builtins::runtime::cancellation::OperationOwner::HostBridge, + Some(&self.run_ctx.cancellation), + None, + None, + ) + .map_err(|error| VmError::HostError(error.to_string()))?; + } + } + self.set_waiting_operation(op_id) + })(); + + if result.is_err() { + let reason = crate::builtins::runtime::cancellation::CancellationReason::ResourceClosed; + if let Some(bridge) = self.host.async_bridge.as_mut() { + bridge.cancel_op_with_reason(op_id, reason); + } + if let Ok(operation_id) = + crate::builtins::runtime::cancellation::OperationId::from_raw(op_id) + && self + .host + .runtime_operations + .get(operation_id) + .is_ok_and(|operation| { + operation.owner() + == crate::builtins::runtime::cancellation::OperationOwner::HostBridge + }) + { + let _ = self.host.runtime_operations.cancel(operation_id, reason); + } + } + result + } + + fn set_waiting_operation(&mut self, op_id: HostOpId) -> VmResult<()> { + if let Some(active) = self.instance.waiting_host_op && active.op_id != op_id { return Err(VmError::HostError(format!( @@ -1728,7 +2012,7 @@ impl Vm { active.op_id, op_id ))); } - self.waiting_host_op = Some(WaitingHostOp { op_id, source }); + self.instance.waiting_host_op = Some(WaitingHostOp { op_id }); Ok(()) } @@ -1737,7 +2021,7 @@ impl Vm { op_id: HostOpId, values: CallReturn, ) -> VmResult<()> { - let waiting = self.waiting_host_op.ok_or_else(|| { + let waiting = self.instance.waiting_host_op.ok_or_else(|| { VmError::HostError(format!( "host op {} completed but vm is not waiting on any op", op_id @@ -1749,8 +2033,8 @@ impl Vm { op_id, waiting.op_id ))); } - self.waiting_host_op = None; - values.push_onto_stack(&mut self.stack); + self.instance.waiting_host_op = None; + values.push_onto_stack(&mut self.instance.stack); Ok(()) } @@ -1763,21 +2047,24 @@ impl Vm { ))); } for &index in &resolved_calls { - if index as usize >= self.host_functions.len() { + if index as usize >= self.host.host_functions.len() { return Err(VmError::InvalidCall(index)); } } - self.resolved_calls = resolved_calls; - self.resolved_calls_dirty = false; + self.host.resolved_calls = resolved_calls; + self.host.resolved_calls_dirty = false; Ok(()) } pub(super) fn ensure_call_bindings(&mut self) -> VmResult<()> { - if self.program.imports.is_empty() || !self.resolved_calls_dirty { + if self.program.imports.is_empty() || !self.host.resolved_calls_dirty { return Ok(()); } - if self.host_function_symbols.is_empty() && self.host_functions.is_empty() { + if self.host.allow_default_host_fallback + && self.host.host_function_symbols.is_empty() + && self.host.host_functions.is_empty() + { let import_names = self .program .imports @@ -1789,49 +2076,54 @@ impl Vm { } } - let use_legacy_order = self.host_function_symbols.is_empty(); + let use_legacy_order = self.host.host_function_symbols.is_empty(); let mut resolved = Vec::with_capacity(self.program.imports.len()); let imports = self.program.imports.clone(); for (index, import) in imports.iter().enumerate() { if use_legacy_order { - if index >= self.host_functions.len() { + if index >= self.host.host_functions.len() { return Err(VmError::InvalidCall(index as u16)); } resolved.push(index as u16); continue; } - let bound = if let Some(bound) = self.host_function_symbols.get(&import.name).copied() { - bound - } else if crate::builtins::runtime::bind_default_host_function(self, &import.name) { - self.host_function_symbols - .get(&import.name) - .copied() - .ok_or_else(|| VmError::UnboundImport(import.name.clone()))? - } else { - return Err(VmError::UnboundImport(import.name.clone())); - }; + let bound = + if let Some(bound) = self.host.host_function_symbols.get(&import.name).copied() { + bound + } else if self.host.allow_default_host_fallback + && crate::builtins::runtime::bind_default_host_function(self, &import.name) + { + self.host + .host_function_symbols + .get(&import.name) + .copied() + .ok_or_else(|| VmError::UnboundImport(import.name.clone()))? + } else { + return Err(VmError::UnboundImport(import.name.clone())); + }; resolved.push(bound); } - self.resolved_calls = resolved; - self.resolved_calls_dirty = false; + self.host.resolved_calls = resolved; + self.host.resolved_calls_dirty = false; Ok(()) } pub(super) fn sync_jit_non_yielding_host_imports(&mut self) { let imports = self + .host .resolved_calls .iter() .map(|&slot| { matches!( - self.host_functions.get(usize::from(slot)), + self.host.host_functions.get(usize::from(slot)), Some(VmHostFunction::ArgsStaticNonYielding(_)) ) }) .collect(); - if self.jit.set_non_yielding_host_imports(imports) { - self.native_traces.clear(); + if self.engine.jit.set_non_yielding_host_imports(imports) { + self.engine.native_traces.clear(); } } @@ -1854,7 +2146,8 @@ impl Vm { }); } - self.resolved_calls + self.host + .resolved_calls .get(index as usize) .copied() .ok_or(VmError::InvalidCall(index)) diff --git a/src/vm/host_runtime.rs b/src/vm/host_runtime.rs new file mode 100644 index 00000000..49b583ae --- /dev/null +++ b/src/vm/host_runtime.rs @@ -0,0 +1,142 @@ +//! Host runtime shell. +//! +//! [`HostRuntime`] owns the host-facing capability surface: bound host +//! functions and their symbol table, capability allow-lists, builtin +//! overrides, resolved call slots, the opaque resource arena, the pending +//! operation registry, a type-erased host-function state store, the async +//! bridge, and the print sink. Concrete IO/HTTP/SQLite state is defined and +//! interpreted only by those host modules. Interpreter state and run budgets +//! live outside this struct (see [`Instance`](super::instance::Instance) and +//! [`RunContext`](super::run_context::RunContext)). +//! +//! The VM provides lifecycle storage without depending on host-specific state +//! types or configuration APIs. + +use std::any::{Any, TypeId}; +use std::collections::{HashMap, HashSet}; + +use crate::builtins::runtime::cancellation::{ + CancellationReason, DEFAULT_MAX_PENDING_OPERATIONS, OperationRegistry, +}; +use crate::builtins::runtime::resource::{DEFAULT_MAX_RESOURCES, ResourceArena}; + +use crate::vm::async_host::HostAsyncBridge; +use crate::vm::host::VmHostFunction; + +/// Embedder-supplied print sink for `print`/`debug` output. +pub(crate) type RuntimePrintSink = dyn FnMut(String) + Send; + +/// Host-owned capabilities, resources, operations, and subsystem state. +/// +/// Thread safety: `HostRuntime` is `!Sync` (host functions, resources, and +/// operations are mutable and not shareable) and not shared; one facade owns +/// one host runtime. Clone semantics: not `Clone` — host bindings and resource +/// handles must not be duplicated across VMs. +pub(crate) struct HostRuntime { + pub(super) host_functions: Vec, + pub(crate) host_function_symbols: HashMap, + pub(crate) allow_default_host_fallback: bool, + pub(crate) allowed_builtin_calls: Vec, + pub(crate) allow_default_builtin_capabilities: bool, + pub(crate) allowed_host_function_slots: Vec, + pub(crate) allow_default_host_capabilities: bool, + pub(crate) builtin_overrides: HashMap, + pub(crate) runtime_owned_pending_host_slots: HashSet, + pub(crate) resolved_calls: Vec, + pub(crate) resolved_calls_dirty: bool, + pub(crate) runtime_resources: ResourceArena, + pub(crate) runtime_operations: OperationRegistry, + host_function_states: HashMap>, + pub(crate) async_bridge: Option>, + pub(crate) submitted_host_ops: HashSet, + pub(crate) runtime_print_sink: Option>, +} + +impl HostRuntime { + /// Creates an empty host runtime with default capability and resource + /// limits and no bound functions. + pub(crate) fn new() -> Self { + Self { + host_functions: Vec::new(), + host_function_symbols: HashMap::new(), + allow_default_host_fallback: true, + allowed_builtin_calls: Vec::new(), + allow_default_builtin_capabilities: true, + allowed_host_function_slots: Vec::new(), + allow_default_host_capabilities: true, + builtin_overrides: HashMap::new(), + runtime_owned_pending_host_slots: HashSet::new(), + resolved_calls: Vec::new(), + resolved_calls_dirty: true, + runtime_resources: ResourceArena::with_limit(DEFAULT_MAX_RESOURCES) + .expect("default runtime resource limit should be valid"), + runtime_operations: OperationRegistry::with_limit(DEFAULT_MAX_PENDING_OPERATIONS) + .expect("default runtime operation limit should be valid"), + host_function_states: HashMap::new(), + async_bridge: None, + submitted_host_ops: HashSet::new(), + runtime_print_sink: None, + } + } + + /// Closes run-scoped host state between runs: pending operations are + /// cancelled, resources are closed, and the IO subsystem is recreated. + /// Host bindings, capability allow-lists, and the async bridge are + /// preserved (documented reusable state). + pub(crate) fn reset_for_reuse(&mut self) { + let _ = self + .runtime_operations + .cancel_all(CancellationReason::VmReset); + let _ = self + .runtime_resources + .close_all(CancellationReason::VmReset); + self.submitted_host_ops.clear(); + } + + pub(crate) fn set_host_function_state(&mut self, state: T) + where + T: Any + Send, + { + self.host_function_states + .insert(TypeId::of::(), Box::new(state)); + } + + pub(crate) fn host_function_state(&self) -> Option<&T> + where + T: Any + Send, + { + self.host_function_states + .get(&TypeId::of::())? + .downcast_ref() + } + + pub(crate) fn host_function_state_mut(&mut self) -> Option<&mut T> + where + T: Any + Send, + { + self.host_function_states + .get_mut(&TypeId::of::())? + .downcast_mut() + } + + pub(crate) fn remove_host_function_state(&mut self) -> Option + where + T: Any + Send, + { + self.host_function_states + .remove(&TypeId::of::())? + .downcast::() + .ok() + .map(|state| *state) + } + + pub(crate) fn default_builtin_capabilities_enabled(&self) -> bool { + self.allow_default_builtin_capabilities + } +} + +impl Default for HostRuntime { + fn default() -> Self { + Self::new() + } +} diff --git a/src/vm/instance.rs b/src/vm/instance.rs new file mode 100644 index 00000000..d74cbad0 --- /dev/null +++ b/src/vm/instance.rs @@ -0,0 +1,295 @@ +//! Interpreter instance state. +//! +//! [`Instance`] owns everything that describes one execution position inside a +//! program: the instruction pointer, operand stack, locals, frames, capture +//! cells, callable ownership, queued callback traffic, waiting/yield state, +//! and instance-only counters. It has no program reference of its own; the +//! immutable [`Program`](crate::bytecode::Program) and the backend +//! [`Engine`](super::engine::Engine) live beside it, so one program can drive +//! many independent instances and a reset only touches this struct. +//! +//! Lifecycle: [`Instance::new`] starts a fresh halted instance; [`Instance::reset`] +//! rewinds run state while keeping configuration and host bindings (owned by +//! the facade); [`Instance::drop_cleanup`] releases interpreter-owned values +//! with drop-contract accounting. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Weak}; + +use crate::bytecode::{CallableValue, Program, SharedCaptureCell, Value}; +use crate::vm::async_host::WaitingHostOp; +use crate::vm::invocation::{InvocationPhase, InvocationState}; +use crate::vm::map_iter::MapIteratorState; +use crate::vm::{DEFAULT_MAX_SCRIPT_CALL_DEPTH, VmYieldReason}; + +#[allow(dead_code)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum FrameContinuation { + Halt, + ResumeBytecode { return_ip: usize }, + ReturnToHost, +} + +#[allow(dead_code)] +#[derive(Clone, Debug)] +pub(crate) struct ExecutionFrame { + pub(crate) continuation: FrameContinuation, + pub(crate) operand_stack_base: usize, + pub(crate) local_base: usize, + pub(crate) local_count: usize, + pub(crate) prototype_id: Option, +} + +impl ExecutionFrame { + pub(crate) fn root(local_count: usize) -> Self { + Self { + continuation: FrameContinuation::Halt, + operand_stack_base: 0, + local_base: 0, + local_count, + prototype_id: None, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct QueuedCallable { + pub(crate) callable: Value, + pub(crate) args: Vec, + pub(crate) subscription: Option>, +} + +/// Interpreter-owned execution state. +/// +/// Thread safety: `Instance` is `!Sync` (it owns mutable interpreter state) +/// and is not shared; the VM facade owns exactly one instance. It is not +/// clonable: cloning would silently duplicate stack/frame/wait state. +pub(crate) struct Instance { + pub(crate) ip: usize, + pub(crate) stack: Vec, + pub(crate) locals: Vec, + pub(crate) capture_cells: HashMap, + pub(crate) shared_capture_slots: HashSet, + pub(crate) execution_frames: Vec, + pub(crate) active_local_base_cache: usize, + pub(crate) active_operand_stack_base_cache: usize, + pub(crate) call_depth: usize, + pub(crate) max_script_call_depth: usize, + pub(crate) host_return: Option, + pub(crate) queued_callables: VecDeque, + pub(crate) completed_callable_results: VecDeque, + pub(crate) owned_callables: Vec>, + pub(crate) callback_registry_flags: Vec>, + pub(crate) draining_queued_callables: bool, + pub(crate) shutdown: bool, + pub(super) waiting_host_op: Option, + pub(crate) last_yield_reason: Option, + pub(crate) invocation: Option, + pub(crate) map_iterators: Vec>>, + pub(crate) drop_contract_events_enabled: bool, + pub(crate) drop_contract_events: u64, + pub(crate) operand_hint_hit_count: u64, + pub(crate) operand_hint_miss_count: u64, + pub(crate) typed_builtin_fast_path_count: u64, + pub(crate) projection_fast_path_count: u64, + pub(crate) generic_builtin_call_count: u64, + pub(crate) scalar_superinstruction_count: u64, + pub(crate) local_type_hint_hit_count: u64, +} + +impl Instance { + /// Creates a halted instance positioned at program entry. + pub(crate) fn new(program: &Program) -> Self { + let local_count = program.local_count; + Self { + ip: 0, + stack: Vec::new(), + locals: vec![Value::Null; local_count], + capture_cells: HashMap::new(), + shared_capture_slots: HashSet::new(), + execution_frames: vec![ExecutionFrame::root(local_count)], + active_local_base_cache: 0, + active_operand_stack_base_cache: 0, + call_depth: 0, + max_script_call_depth: DEFAULT_MAX_SCRIPT_CALL_DEPTH, + host_return: None, + queued_callables: VecDeque::new(), + completed_callable_results: VecDeque::new(), + owned_callables: Vec::new(), + callback_registry_flags: Vec::new(), + draining_queued_callables: false, + shutdown: false, + waiting_host_op: None, + last_yield_reason: None, + invocation: None, + map_iterators: Vec::new(), + drop_contract_events_enabled: false, + drop_contract_events: 0, + operand_hint_hit_count: 0, + operand_hint_miss_count: 0, + typed_builtin_fast_path_count: 0, + projection_fast_path_count: 0, + generic_builtin_call_count: 0, + scalar_superinstruction_count: 0, + local_type_hint_hit_count: 0, + } + } + + /// Rewinds run-scoped interpreter state for a fresh execution of the same + /// program. Host bindings, backend configuration, and compiled artifacts + /// (owned outside this struct) are preserved. + pub(crate) fn reset(&mut self, program: &Program) { + self.invalidate_callback_registries(); + self.ip = 0; + self.drop_contract_events = 0; + self.last_yield_reason = None; + self.clear_stack_with_drop_contract(); + self.capture_cells.clear(); + self.shared_capture_slots.clear(); + self.clear_locals_with_drop_contract(); + self.owned_callables.clear(); + self.locals.resize(program.local_count, Value::Null); + self.initialize_root_callable_bindings(program); + self.call_depth = 0; + self.execution_frames.clear(); + self.execution_frames + .push(ExecutionFrame::root(program.local_count)); + self.active_local_base_cache = 0; + self.active_operand_stack_base_cache = 0; + self.host_return = None; + self.queued_callables.clear(); + self.completed_callable_results.clear(); + self.owned_callables.clear(); + self.draining_queued_callables = false; + self.shutdown = false; + self.waiting_host_op = None; + self.drop_invocation_state(); + self.invocation = None; + self.map_iterators.clear(); + self.clear_interpreter_metrics(); + } + + /// Releases interpreter-owned values with drop-contract accounting. Used by + /// the facade's `Drop` (and by `shutdown`). + pub(crate) fn drop_cleanup(&mut self) { + self.drop_invocation_state(); + self.clear_stack_with_drop_contract(); + self.capture_cells.clear(); + self.shared_capture_slots.clear(); + self.clear_locals_with_drop_contract(); + } + + /// Drops pending invocation stream values with drop-contract accounting and + /// rewinds the invocation state to a fresh, fused position. + pub(crate) fn drop_invocation_state(&mut self) { + let Some(state) = self.invocation.as_mut() else { + return; + }; + let value = match std::mem::replace(&mut state.phase, InvocationPhase::Fused) { + InvocationPhase::EventPending(value) | InvocationPhase::CompletePending(value) => { + Some(value) + } + _ => None, + }; + state.emit_yield_pending = false; + state.pending_error = None; + if let Some(value) = value { + self.drop_value_with_contract(value); + } + } + + pub(crate) fn invalidate_callback_registries(&mut self) { + for active in self + .callback_registry_flags + .drain(..) + .filter_map(|flag| flag.upgrade()) + { + active.store(false, std::sync::atomic::Ordering::Release); + } + } + + pub(crate) fn register_callback_registry(&mut self, active: &Arc) { + self.callback_registry_flags.push(Arc::downgrade(active)); + } + + pub(crate) fn initialize_root_callable_bindings(&mut self, program: &Program) { + let bindings = program.root_callable_bindings.clone(); + for binding in bindings { + let Some(kind) = program + .callable_prototypes + .get(binding.prototype_id as usize) + .map(|prototype| prototype.kind) + else { + continue; + }; + if binding.local_slot as usize >= self.locals.len() { + continue; + } + let callable = Arc::new(CallableValue { + prototype_id: binding.prototype_id, + kind, + env: None, + }); + self.owned_callables.push(Arc::downgrade(&callable)); + self.locals[binding.local_slot as usize] = Value::Callable(callable); + } + } + + pub(crate) fn clear_interpreter_metrics(&mut self) { + self.operand_hint_hit_count = 0; + self.operand_hint_miss_count = 0; + self.typed_builtin_fast_path_count = 0; + self.projection_fast_path_count = 0; + self.generic_builtin_call_count = 0; + self.scalar_superinstruction_count = 0; + self.local_type_hint_hit_count = 0; + } + + pub(crate) fn clear_stack_with_drop_contract(&mut self) { + let drained = self.stack.drain(..).collect::>(); + for value in drained { + self.drop_value_with_contract(value); + } + } + + pub(crate) fn clear_locals_with_drop_contract(&mut self) { + for slot in 0..self.locals.len() { + let previous = std::mem::replace(&mut self.locals[slot], Value::Null); + self.drop_value_with_contract(previous); + } + } + + pub(crate) fn drop_value_with_contract(&mut self, value: Value) { + if self.drop_contract_events_enabled { + self.count_value_drop_contract(&value); + } + } + + pub(crate) fn count_value_drop_contract(&mut self, value: &Value) { + match value { + Value::Null => {} + Value::Array(values) => { + self.drop_contract_events = self.drop_contract_events.saturating_add(1); + for item in values.iter() { + self.count_value_drop_contract(item); + } + } + Value::Map(entries) => { + self.drop_contract_events = self.drop_contract_events.saturating_add(1); + for (key, value) in entries.iter() { + self.count_value_drop_contract(key); + self.count_value_drop_contract(value); + } + } + Value::Int(_) + | Value::Float(_) + | Value::Bool(_) + | Value::String(_) + | Value::Bytes(_) + | Value::Callable(_) => { + self.drop_contract_events = self.drop_contract_events.saturating_add(1); + } + } + } +} diff --git a/src/vm/invocation.rs b/src/vm/invocation.rs new file mode 100644 index 00000000..408fd095 --- /dev/null +++ b/src/vm/invocation.rs @@ -0,0 +1,525 @@ +//! Invocation item stream. +//! +//! One exported callable started with ordinary arguments behaves like +//! `Stream>`: zero or more +//! `Event` items produced by `stream::emit`, then exactly one `Complete` item +//! or one typed error, then a fused end of stream. Polling drives execution; +//! the VM does not produce items while the consumer is not polling, and at most +//! one event item is buffered between polls (natural backpressure). +//! +//! The invocation reuses the existing callable execution state +//! ([`Vm::start_callable`], [`Vm::run`], [`Vm::take_callable_result`]) and the +//! existing async host bridge; it does not duplicate interpreter or host loops, +//! and it does not add an executor, generator syntax, an event queue, or event +//! persistence policy. + +use std::fmt; +use std::task::{Context, Poll, Waker}; + +use crate::builtins::runtime::cancellation::{ + CancellationReason, CancellationToken, OperationId, OperationState, OperationStatus, +}; +use crate::builtins::runtime::error::RuntimeError; +use crate::vm::{CallOutcome, CallReturn, Value, Vm, VmError, VmResult, VmStatus, VmYieldReason}; + +/// One item yielded by an invocation stream. +#[derive(Clone, Debug, PartialEq)] +pub enum InvocationItem { + /// One bounded event produced by `stream::emit(value)`. + Event(Value), + /// The callable's return value; exactly one per invocation. + Complete(Value), +} + +/// Typed terminal failure of an invocation stream. +/// +/// The failure is machine-readable: cancellation keeps its reason, fuel and +/// deadline failures keep their numeric state, and runtime capability failures +/// keep their structured [`RuntimeError`] instead of being flattened to a +/// string. +#[derive(Debug)] +pub enum InvocationError { + /// The invocation was cancelled with this reason. + Cancelled(CancellationReason), + /// The configured fuel budget was exhausted. + OutOfFuel { needed: u64, remaining: u64 }, + /// The configured epoch deadline expired. + DeadlineReached { current: u64, deadline: u64 }, + /// A runtime capability failure with its machine-readable code. + Capability(RuntimeError), + /// An embedding host failure without a structured runtime code. + Host { message: String }, + /// A low-level VM failure (script error or invalid frame state). + Vm(VmError), +} + +/// Poll outcome of an invocation stream. +#[derive(Debug)] +pub enum InvocationPoll { + /// The VM is paused (waiting on a host operation or a host-driven yield); + /// drive the outstanding work and poll again. + Pending, + /// One stream item, or `None` after the fused end of stream. + Ready(Option>), +} + +/// Run-scoped state of the single active invocation on a VM. +#[derive(Debug)] +pub(crate) struct InvocationState { + pub(crate) phase: InvocationPhase, + /// True while the VM is yielded at a `stream::emit` call site whose event + /// has already been delivered. The resumed call site re-enters + /// `stream::emit` and consumes this marker instead of emitting a second + /// event for the same call. + pub(crate) emit_yield_pending: bool, + /// A structured runtime error produced by `stream::emit` validation, + /// preserved for the terminal error item without string flattening. + pub(crate) pending_error: Option, + /// Stack and frame position recorded when the invocation started, used to + /// release interpreter state on terminal failure. + pub(crate) stack_base: usize, + pub(crate) frame_count: usize, +} + +#[derive(Debug)] +pub(crate) enum InvocationPhase { + Running, + EventPending(Value), + CompletePending(Value), + ErrorPending(InvocationError), + Fused, +} + +/// 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). +pub struct Invocation<'vm> { + vm: &'vm mut Vm, +} + +impl fmt::Debug for Invocation<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Invocation").finish_non_exhaustive() + } +} + +impl Invocation<'_> { + /// Polls the invocation stream. + /// + /// Returns `Ready(Some(Ok(Event(value))))` for each emitted event, + /// `Ready(Some(Ok(Complete(value))))` exactly once for the callable return + /// value, `Ready(Some(Err(error)))` exactly once for a typed terminal + /// failure, and `Ready(None)` on every poll after the stream has fused. + /// `Pending` means the VM is paused on an outstanding host operation or + /// host-driven yield; drive it and poll again. + pub fn poll_next(&mut self) -> VmResult { + self.vm.poll_invocation() + } + + /// Cancels the active invocation with a typed reason. + /// + /// Outstanding owned host operations are cancelled with the same reason. + /// The next poll produces exactly one `Cancelled(reason)` error item, after + /// which the stream is fused. + 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); + cancellation_result + } +} + +/// One poll step selected from the current invocation phase. +enum InvocationAction { + Cancelled, + Event, + Complete, + Error, + Fused, + Drive, +} + +impl Vm { + /// Starts one invocation of an exported callable with ordinary arguments. + /// + /// The VM must be halted (complete the root frame with [`Vm::run`] first), + /// and must not already have an active invocation. A second invocation on + /// the same VM is rejected while one is active. + pub fn start_invocation( + &mut self, + callable: Value, + args: Vec, + ) -> VmResult> { + if !matches!(callable, Value::Callable(_)) { + return Err(VmError::InvalidCallable); + } + if self + .instance + .invocation + .as_ref() + .is_some_and(|state| !matches!(state.phase, InvocationPhase::Fused)) + { + return Err(VmError::InvalidFrameState( + "an invocation is already active on this vm", + )); + } + let stack_base = self.instance.stack.len(); + let frame_count = self.instance.execution_frames.len(); + self.instance.invocation = Some(InvocationState { + phase: InvocationPhase::Running, + emit_yield_pending: false, + pending_error: None, + stack_base, + frame_count, + }); + + // A cancellation that predates the invocation terminates it + // immediately. No callable, frame, or host operation has started yet, + // so there is nothing to release here: the stream transitions + // directly to the typed error and normal error delivery releases the + // invocation exactly once when the item is consumed. + if let Some(reason) = self.run_ctx.cancellation.reason() { + self.instance + .invocation + .as_mut() + .expect("invocation state") + .phase = InvocationPhase::ErrorPending(InvocationError::Cancelled(reason)); + return Ok(Invocation { vm: self }); + } + + match self.start_callable(callable, &args) { + Ok(VmStatus::Halted) => { + let result = self + .take_callable_result() + .ok_or(VmError::InvalidFrameState( + "invocation halted without a callable result", + ))?; + self.instance + .invocation + .as_mut() + .expect("invocation state") + .phase = InvocationPhase::CompletePending(result); + } + Ok(VmStatus::Yielded) => { + // Either `stream::emit` placed one pending event, or the + // embedding must drive a host-owned yield; both are serviced by + // the next poll. + } + Ok(VmStatus::Waiting(_)) => {} + Err(error) => { + let error = self.map_invocation_error(error, None); + self.release_invocation(); + self.instance + .invocation + .as_mut() + .expect("invocation state") + .phase = InvocationPhase::ErrorPending(error); + } + } + Ok(Invocation { vm: self }) + } + + fn poll_invocation(&mut self) -> VmResult { + loop { + let action = match self.instance.invocation.as_ref() { + Some(state) => { + // Authoritative cancellation supersedes a pending Event or + // Complete: the pending value is discarded (through the + // drop-contract path) and the stream transitions to one + // Cancelled item, then a fused end. + if self.run_ctx.cancellation.reason().is_some() + && matches!( + state.phase, + InvocationPhase::EventPending(_) | InvocationPhase::CompletePending(_) + ) + { + InvocationAction::Cancelled + } else { + match state.phase { + InvocationPhase::EventPending(_) => InvocationAction::Event, + InvocationPhase::CompletePending(_) => InvocationAction::Complete, + InvocationPhase::ErrorPending(_) => InvocationAction::Error, + InvocationPhase::Fused => InvocationAction::Fused, + InvocationPhase::Running => InvocationAction::Drive, + } + } + } + None => return Ok(InvocationPoll::Ready(None)), + }; + match action { + InvocationAction::Cancelled => { + let reason = self + .run_ctx + .cancellation + .reason() + .expect("a cancelled action requires a cancellation reason"); + let discarded = self.replace_invocation_phase(InvocationPhase::ErrorPending( + InvocationError::Cancelled(reason), + )); + match discarded { + InvocationPhase::EventPending(value) + | InvocationPhase::CompletePending(value) => { + self.drop_value_with_contract(value); + } + _ => unreachable!("the cancelled action matched a pending phase above"), + } + } + InvocationAction::Event => { + let value = match self.replace_invocation_phase(InvocationPhase::Running) { + InvocationPhase::EventPending(value) => value, + _ => unreachable!("phase matched above"), + }; + // `emit_yield_pending` stays set until the resumed call + // site re-enters `stream::emit`. + return Ok(InvocationPoll::Ready(Some(Ok(InvocationItem::Event( + value, + ))))); + } + InvocationAction::Complete => { + let value = match self.replace_invocation_phase(InvocationPhase::Fused) { + InvocationPhase::CompletePending(value) => value, + _ => unreachable!("phase matched above"), + }; + self.release_invocation(); + return Ok(InvocationPoll::Ready(Some(Ok(InvocationItem::Complete( + value, + ))))); + } + InvocationAction::Error => { + let error = match self.replace_invocation_phase(InvocationPhase::Fused) { + InvocationPhase::ErrorPending(error) => error, + _ => unreachable!("phase matched above"), + }; + self.release_invocation(); + return Ok(InvocationPoll::Ready(Some(Err(error)))); + } + InvocationAction::Fused => return Ok(InvocationPoll::Ready(None)), + InvocationAction::Drive => { + let result = self.drive_invocation(); + match result { + DriveOutcome::Continue => {} + DriveOutcome::Pending => return Ok(InvocationPoll::Pending), + DriveOutcome::Error(error) => { + self.release_invocation(); + self.instance + .invocation + .as_mut() + .expect("invocation state") + .phase = InvocationPhase::ErrorPending(error); + } + } + } + } + } + } + + /// Runs the low-level pump once and folds the outcome into the invocation + /// phase. `Vm::run` itself is unchanged. + fn drive_invocation(&mut self) -> DriveOutcome { + if let Some(reason) = self.run_ctx.cancellation.reason() { + return DriveOutcome::Error(InvocationError::Cancelled(reason)); + } + match self.run() { + Ok(VmStatus::Halted) => { + let result = match self.take_callable_result() { + Some(result) => result, + None => { + return DriveOutcome::Error(InvocationError::Vm( + VmError::InvalidFrameState( + "invocation halted without a callable result", + ), + )); + } + }; + self.instance + .invocation + .as_mut() + .expect("invocation state") + .phase = InvocationPhase::CompletePending(result); + DriveOutcome::Continue + } + Ok(VmStatus::Yielded) => match self.last_yield_reason() { + Some(VmYieldReason::Fuel) => DriveOutcome::Error(InvocationError::OutOfFuel { + needed: u64::from(self.run_ctx.fuel_check_interval), + remaining: self.run_ctx.fuel_remaining, + }), + Some(VmYieldReason::Epoch) => { + DriveOutcome::Error(InvocationError::DeadlineReached { + current: self.run_ctx.epoch_handle.current(), + deadline: self.run_ctx.epoch_deadline, + }) + } + _ => { + // A `stream::emit` yield leaves one pending event; any other + // host-driven yield is paused for the embedding. + let event_pending = matches!( + self.instance.invocation.as_ref().map(|state| &state.phase), + Some(InvocationPhase::EventPending(_)) + ); + if event_pending { + DriveOutcome::Continue + } else { + DriveOutcome::Pending + } + } + }, + Ok(VmStatus::Waiting(_)) => { + // Capture the waiting operation AFTER `run()`: the step may + // have registered a new host op. The operation state is + // retained before polling because failing the operation + // removes it from the registry; `map_invocation_error` must + // still be able to recover its typed `OperationStatus::Failed` + // error after the first poll clears the waiting state. + let waiting_operation = self.capture_waiting_operation(); + // Poll the outstanding host operation once with a noop waker. + // The embedding-owned driver completes it; re-polling observes + // readiness. + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + match self.poll_waiting_host_op(&mut cx) { + Poll::Ready(Ok(())) => DriveOutcome::Continue, + Poll::Ready(Err(error)) => { + DriveOutcome::Error(self.map_invocation_error(error, waiting_operation)) + } + Poll::Pending => DriveOutcome::Pending, + } + } + Err(error) => { + // `run()` may have registered a new host op before failing; + // retain its operation state for typed error mapping. + let waiting_operation = self.capture_waiting_operation(); + DriveOutcome::Error(self.map_invocation_error(error, waiting_operation)) + } + } + } + + /// Captures the state of the host operation the VM is waiting on, if any. + /// + /// The waiting state must be captured after `run()` (the step may have + /// registered a new host op) and before a poll that may fail and remove + /// the operation from the registry: `map_invocation_error` needs the + /// retained state to recover the typed `OperationStatus::Failed` error + /// once the waiting state has been cleared. + fn capture_waiting_operation(&self) -> Option { + self.instance + .waiting_host_op + .and_then(|op| OperationId::from_raw(op.op_id).ok()) + .and_then(|operation_id| self.host.runtime_operations.get(operation_id).ok()) + } + + /// Maps a low-level VM failure to the typed invocation error, preserving + /// structured runtime errors from `stream::emit` validation and from failed + /// host operations. The waiting operation state is captured by the caller + /// before the poll that may fail and remove it from the registry. + fn map_invocation_error( + &mut self, + error: VmError, + waiting_operation: Option, + ) -> InvocationError { + if let Some(state) = self.instance.invocation.as_mut() + && let Some(runtime_error) = state.pending_error.take() + { + return InvocationError::Capability(runtime_error); + } + if let Some(operation) = waiting_operation + && let OperationStatus::Failed(runtime_error) = operation.status() + { + return InvocationError::Capability(runtime_error); + } + match error { + VmError::OutOfFuel { needed, remaining } => { + InvocationError::OutOfFuel { needed, remaining } + } + VmError::EpochDeadlineReached { current, deadline } => { + InvocationError::DeadlineReached { current, deadline } + } + VmError::HostError(message) => InvocationError::Host { message }, + other => InvocationError::Vm(other), + } + } + + /// Replaces the active invocation phase, returning the previous one so the + /// caller can consume it or drop it (the pending-event drop contract stays + /// with the caller). + fn replace_invocation_phase(&mut self, phase: InvocationPhase) -> InvocationPhase { + std::mem::replace( + &mut self + .instance + .invocation + .as_mut() + .expect("invocation state") + .phase, + phase, + ) + } + + /// Releases the active invocation: cancels outstanding owned operations, + /// drops interpreter frames and stack entries introduced by the + /// invocation, and fuses the stream. + /// + /// Releasing is the invocation boundary for VM-level cancellation: the + /// run-context cancellation token is replaced with a fresh root so the + /// reason consumed by this invocation (or any stale pre-invocation + /// cancellation) cannot leak into a later invocation on the same VM. + /// Outstanding operations were cancelled above; operations registered by + /// a later invocation attach to the fresh token, preserving per-invocation + /// parent cancellation semantics. + fn release_invocation(&mut self) { + let (stack_base, frame_count) = self + .instance + .invocation + .as_ref() + .map(|state| (state.stack_base, state.frame_count)) + .unwrap_or((0, 0)); + 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; + } + self.run_ctx.cancellation = CancellationToken::root(); + } + + /// Implements the script-visible `stream::emit(value)` builtin: validates + /// the per-item bound, places one pending event, and yields control to the + /// invocation poller. `stream::emit` still evaluates to `()` inside RSS. + /// + /// When the poller has delivered the event and the VM resumes, the call + /// site re-executes; the second entry consumes the `emit_yield_pending` + /// marker and returns normally instead of emitting a second event. + pub(crate) fn emit_stream_item(&mut self, value: Value) -> VmResult { + let state = self.instance.invocation.as_mut().ok_or_else(|| { + VmError::HostError("stream::emit requires an active invocation".to_string()) + })?; + if !matches!(state.phase, InvocationPhase::Running) { + return Err(VmError::HostError( + "stream::emit is only valid while the invocation is running".to_string(), + )); + } + if state.emit_yield_pending { + state.emit_yield_pending = false; + return Ok(CallOutcome::Return(CallReturn::none())); + } + let limits = self.run_ctx.runtime_context.event_limits(); + match crate::builtins::runtime::event::EventPayload::try_new(value, limits) { + Ok(payload) => { + state.phase = InvocationPhase::EventPending(payload.into_value()); + state.emit_yield_pending = true; + Ok(CallOutcome::Yield) + } + Err(runtime_error) => { + let message = runtime_error.to_string(); + state.pending_error = Some(runtime_error); + Err(VmError::HostError(message)) + } + } + } +} + +/// Outcome of one low-level drive step. +enum DriveOutcome { + Continue, + Pending, + Error(InvocationError), +} diff --git a/src/vm/jit/diagnostics.rs b/src/vm/jit/diagnostics.rs index 9fe0dafe..47eb71e0 100644 --- a/src/vm/jit/diagnostics.rs +++ b/src/vm/jit/diagnostics.rs @@ -3,11 +3,12 @@ use super::{JitMetrics, JitSnapshot, native}; impl Vm { pub(super) fn jit_diagnostics_snapshot(&self) -> JitSnapshot { - self.jit.snapshot(self.jit_diagnostics_metrics()) + self.engine.jit.snapshot(self.jit_diagnostics_metrics()) } pub(super) fn jit_diagnostics_dump(&self, include_machine_code: bool) -> String { let mut out = self + .engine .jit .dump_text(self.program.debug.as_ref(), self.jit_diagnostics_metrics()); out.push_str(&format!( @@ -16,35 +17,36 @@ impl Vm { )); out.push_str(&format!( " native trace executions: {}\n", - self.native_trace_exec_count + self.engine.native_trace_exec_count )); out.push_str(&format!( " native trace handoffs: {}\n", - self.jit_native_link_handoff_count + self.engine.jit_native_link_handoff_count )); out.push_str(&format!( " native region entries: {}\n", - self.jit_native_region_entry_count + self.engine.jit_native_region_entry_count )); out.push_str(&format!( " native internal region edges: {}\n", - self.jit_native_region_edge_count + self.engine.jit_native_region_edge_count )); out.push_str(&format!( " native direct side links: {}\n", - self.jit_native_direct_link_count + self.engine.jit_native_direct_link_count )); out.push_str(&format!( " native compile time: {} ns (regions={} ns)\n", - self.jit_native_compile_time_ns, self.jit_native_region_compile_time_ns + self.engine.jit_native_compile_time_ns, self.engine.jit_native_region_compile_time_ns )); out.push_str(&format!( " native code bytes: {} (regions={})\n", self.jit_native_code_bytes(), self.jit_native_region_code_bytes() )); - if self.jit_native_bridge_stats_enabled { + if self.engine.jit_native_bridge_stats_enabled { let mut bridge_entries: Vec<(&'static str, u64)> = self + .engine .jit_native_bridge_counts .iter() .map(|(name, count)| (*name, *count)) @@ -62,14 +64,14 @@ impl Vm { out.push_str(&format!(" bridge {}: {}\n", name, count)); } } - let native_trace_count = self.native_traces.iter().flatten().count(); + let native_trace_count = self.engine.native_traces.iter().flatten().count(); if native_trace_count == 0 { out.push_str(" native traces: 0\n"); return out; } out.push_str(&format!(" native traces: {}\n", native_trace_count)); - for (id, native) in self.native_traces.iter().enumerate() { + for (id, native) in self.engine.native_traces.iter().enumerate() { if let Some(native) = native { out.push_str(&format!( " native trace#{} entry=0x{:X} code_bytes={} lowering={}\n", @@ -109,10 +111,10 @@ impl Vm { JitMetrics { boxed_load_site_count: 0, boxed_store_site_count: 0, - trace_exit_count: self.jit_trace_exit_count, - native_loop_back_count: self.jit_native_loop_back_count, - helper_fallback_count: self.jit_helper_fallback_count, - native_trace_exec_count: self.native_trace_exec_count, + trace_exit_count: self.engine.jit_trace_exit_count, + native_loop_back_count: self.engine.jit_native_loop_back_count, + helper_fallback_count: self.engine.jit_helper_fallback_count, + native_trace_exec_count: self.engine.native_trace_exec_count, script_call_observations: 0, monomorphic_call_sites: 0, polymorphic_call_sites: 0, diff --git a/src/vm/jit/inline.rs b/src/vm/jit/inline.rs index 0a0cc1e0..36b86cf2 100644 --- a/src/vm/jit/inline.rs +++ b/src/vm/jit/inline.rs @@ -57,12 +57,63 @@ pub(crate) fn classify_static_inline_candidate( if bindings.next().is_some() { return Err(InlineRejectReason::PolymorphicTarget); } - if caller_prototype_id == Some(binding.prototype_id) { + classify_prototype_inline_candidate( + program, + binding.prototype_id, + caller_prototype_id, + argc, + remaining_trace_budget, + ) +} + +/// Classify an inline candidate for a static `CallScript` call site. +/// +/// The prototype identity comes from the instruction operands instead of a +/// runtime callable local, so no `root_callable_bindings` lookup or +/// polymorphic guard is needed. Environment-free eligibility mirrors the +/// interpreter contract: `CallScript` can never supply captures or a self +/// binding, so such prototypes are rejected here exactly like +/// `CallScriptRequiresEnvironment` at runtime. +pub(crate) fn classify_direct_inline_candidate( + program: &Program, + caller_frame_key: u64, + caller_prototype_id: Option, + prototype_id: u32, + argc: u8, + remaining_trace_budget: usize, +) -> Result { + if caller_frame_key != ROOT_FRAME_KEY { + return Err(InlineRejectReason::NonRootCaller); + } + let prototype = program + .callable_prototypes + .get(prototype_id as usize) + .ok_or(InlineRejectReason::UnknownTarget)?; + if prototype.self_slot.is_some() { + return Err(InlineRejectReason::CapturedCallable); + } + classify_prototype_inline_candidate( + program, + prototype_id, + caller_prototype_id, + argc, + remaining_trace_budget, + ) +} + +fn classify_prototype_inline_candidate( + program: &Program, + prototype_id: u32, + caller_prototype_id: Option, + argc: u8, + remaining_trace_budget: usize, +) -> Result { + if caller_prototype_id == Some(prototype_id) { return Err(InlineRejectReason::Recursive); } let prototype = program .callable_prototypes - .get(binding.prototype_id as usize) + .get(prototype_id as usize) .ok_or(InlineRejectReason::UnknownTarget)?; if prototype.kind != CallableKind::FunctionItem || !prototype.capture_slots.is_empty() @@ -99,7 +150,7 @@ pub(crate) fn classify_static_inline_candidate( return Err(InlineRejectReason::TraceBudgetExceeded); } Ok(InlineCandidate { - prototype_id: binding.prototype_id, + prototype_id, entry_ip, end_ip, parameter_slots: prototype.parameter_slots.clone(), @@ -173,6 +224,9 @@ fn scan_inline_region( } } OpCode::CallValue => return Err(InlineRejectReason::NestedScriptCall), + // `CallScript` is a nested script call too; inline analysis + // support for the direct path lands with backend parity. + OpCode::CallScript => return Err(InlineRejectReason::NestedScriptCall), OpCode::Call => { let index = read_u16(&program.code, &mut ip).ok_or(InlineRejectReason::UnknownTarget)?; diff --git a/src/vm/jit/ir.rs b/src/vm/jit/ir.rs index 5b3e50f6..3c8c16fd 100644 --- a/src/vm/jit/ir.rs +++ b/src/vm/jit/ir.rs @@ -563,6 +563,15 @@ pub(crate) enum SsaTerminator { resume_ip: usize, exit: SsaExitId, }, + /// Static direct script-function call: the callee prototype is part of + /// the instruction, so no runtime callable value is consumed. + CallScript { + prototype_id: u32, + argc: u8, + call_ip: usize, + resume_ip: usize, + exit: SsaExitId, + }, } #[derive(Clone, Debug, PartialEq)] @@ -1044,7 +1053,8 @@ fn verify_terminator( } SsaTerminator::Exit { exit } | SsaTerminator::Return { exit } - | SsaTerminator::CallValue { exit, .. } => { + | SsaTerminator::CallValue { exit, .. } + | SsaTerminator::CallScript { exit, .. } => { if !exit_ids.contains(exit) { return Err(SsaVerifyError::UnknownExit(*exit)); } @@ -1277,6 +1287,15 @@ fn render_terminator(terminator: &SsaTerminator) -> String { resume_ip, exit, } => format!("call_value argc={argc} call_ip={call_ip} resume_ip={resume_ip} {exit}"), + SsaTerminator::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + exit, + } => format!( + "call_script prototype={prototype_id} argc={argc} call_ip={call_ip} resume_ip={resume_ip} {exit}" + ), } } diff --git a/src/vm/jit/native/lower.rs b/src/vm/jit/native/lower.rs index 5eb8a5af..d13c9913 100644 --- a/src/vm/jit/native/lower.rs +++ b/src/vm/jit/native/lower.rs @@ -17,6 +17,7 @@ use crate::vm::native::{ clear_bridge_error_entry_address, clear_value_slot_entry_address, clone_value_signature, clone_value_to_slot_entry_address, collection_get_signature, collection_predicate_signature, copy_bytes_entry_address, copy_bytes_signature, detect_native_stack_layout, + enter_call_script_inherited_entry_address, enter_call_script_inherited_signature, enter_call_value_inherited_entry_address, enter_call_value_inherited_signature, entry_signature, frame_state_entry_address, frame_state_signature, free_buffer_signature, jump_with_status, leave_frame_inherited_entry_address, leave_frame_inherited_signature, @@ -619,6 +620,7 @@ fn try_compile_ssa_trace( let frame_state_sig = frame_state_signature(pointer_type, call_conv); let leave_frame_sig = leave_frame_inherited_signature(pointer_type, call_conv); let enter_call_value_sig = enter_call_value_inherited_signature(pointer_type, call_conv); + let enter_call_script_sig = enter_call_script_inherited_signature(pointer_type, call_conv); let resume_linked_trace_sig = entry_signature(pointer_type, call_conv); let string_contains_sig = string_contains_signature(pointer_type, call_conv); @@ -702,6 +704,7 @@ fn try_compile_ssa_trace( restore_virtual_frame_ref: b.import_signature(restore_virtual_frame_sig), leave_frame_ref: b.import_signature(leave_frame_sig), enter_call_value_ref: b.import_signature(enter_call_value_sig), + enter_call_script_ref: b.import_signature(enter_call_script_sig), resume_linked_trace_ref: b.import_signature(resume_linked_trace_sig), }; @@ -728,6 +731,7 @@ fn try_compile_ssa_trace( restore_virtual_frame: restore_virtual_frame_entry_address(), leave_frame: leave_frame_inherited_entry_address(), enter_call_value: enter_call_value_inherited_entry_address(), + enter_call_script: enter_call_script_inherited_entry_address(), resume_linked_trace: resume_linked_trace_entry_address(), }; @@ -780,7 +784,30 @@ fn try_compile_ssa_trace( call_ip, resume_ip, exit, - }) => Some((*exit, (*argc, *call_ip, *resume_ip))), + }) => Some(( + *exit, + SsaCallExit { + prototype_id: None, + argc: *argc, + call_ip: *call_ip, + resume_ip: *resume_ip, + }, + )), + Some(SsaTerminator::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + exit, + }) => Some(( + *exit, + SsaCallExit { + prototype_id: Some(*prototype_id), + argc: *argc, + call_ip: *call_ip, + resume_ip: *resume_ip, + }, + )), _ => None, }) .collect::>(); @@ -1034,18 +1061,38 @@ fn try_compile_ssa_trace( }, )?; lower_ssa_exit_block(&mut b, lower_ctx, exit, spec, SsaExitAction::Return)?; - if let Some((argc, call_ip, resume_ip)) = call_value_exits.get(&exit.id).copied() { - lower_ssa_exit_block( - &mut b, - lower_ctx, - exit, - spec, - SsaExitAction::CallValue { - argc, - call_ip, - resume_ip, - }, - )?; + if let Some(call_exit) = call_value_exits.get(&exit.id).copied() { + let SsaCallExit { + prototype_id, + argc, + call_ip, + resume_ip, + } = call_exit; + match prototype_id { + None => lower_ssa_exit_block( + &mut b, + lower_ctx, + exit, + spec, + SsaExitAction::CallValue { + argc, + call_ip, + resume_ip, + }, + )?, + Some(prototype_id) => lower_ssa_exit_block( + &mut b, + lower_ctx, + exit, + spec, + SsaExitAction::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + }, + )?, + } } if spec.interrupt_block.is_some() { lower_ssa_exit_block(&mut b, lower_ctx, exit, spec, SsaExitAction::InterruptYield)?; @@ -1109,6 +1156,16 @@ struct SsaExitLowering { inputs: Vec, } +#[derive(Clone, Copy)] +struct SsaCallExit { + /// `None` for dynamic `CallValue`; `Some(prototype_id)` for static + /// `CallScript` boundaries. + prototype_id: Option, + argc: u8, + call_ip: usize, + resume_ip: usize, +} + #[derive(Clone, Copy)] enum SsaExitAction { TraceExit { @@ -1120,6 +1177,12 @@ enum SsaExitAction { call_ip: usize, resume_ip: usize, }, + CallScript { + prototype_id: u32, + argc: u8, + call_ip: usize, + resume_ip: usize, + }, InterruptYield, } @@ -1147,6 +1210,7 @@ struct SsaDeoptHelperRefs { restore_virtual_frame_ref: cranelift_codegen::ir::SigRef, leave_frame_ref: cranelift_codegen::ir::SigRef, enter_call_value_ref: cranelift_codegen::ir::SigRef, + enter_call_script_ref: cranelift_codegen::ir::SigRef, resume_linked_trace_ref: cranelift_codegen::ir::SigRef, } @@ -1175,6 +1239,7 @@ struct SsaDeoptHelperAddrs { restore_virtual_frame: usize, leave_frame: usize, enter_call_value: usize, + enter_call_script: usize, resume_linked_trace: usize, } @@ -1602,7 +1667,8 @@ fn borrowed_array_get_outputs(ssa: &SsaTrace) -> BTreeSet { } SsaTerminator::Exit { .. } | SsaTerminator::Return { .. } - | SsaTerminator::CallValue { .. } => {} + | SsaTerminator::CallValue { .. } + | SsaTerminator::CallScript { .. } => {} } } for exit in &ssa.exits { @@ -1816,7 +1882,8 @@ fn ssa_backedge_targets( } SsaTerminator::Exit { .. } | SsaTerminator::Return { .. } - | SsaTerminator::CallValue { .. } => {} + | SsaTerminator::CallValue { .. } + | SsaTerminator::CallScript { .. } => {} } targets } @@ -4234,7 +4301,7 @@ fn lower_ssa_terminator( let args = ssa_block_args(args); b.ins().jump(spec.halted_block, &args); } - SsaTerminator::CallValue { exit, .. } => { + SsaTerminator::CallValue { exit, .. } | SsaTerminator::CallScript { exit, .. } => { let spec = exit_specs.get(exit).ok_or_else(|| { VmError::JitNative("SSA call-value exit lowering missing".to_string()) })?; @@ -4487,6 +4554,41 @@ fn ssa_exit_action_status( ); Ok(b.inst_results(call)[0]) } + SsaExitAction::CallScript { + prototype_id, + argc, + call_ip, + resume_ip, + } => { + let helper_ptr = iconst_ptr_from_addr(b, pointer_type, helper_addrs.enter_call_script)?; + let prototype_id = b.ins().iconst(types::I64, i64::from(prototype_id)); + let argc = b.ins().iconst(types::I64, i64::from(argc)); + let call_ip = b.ins().iconst( + types::I64, + i64::try_from(call_ip).map_err(|_| { + VmError::JitNative("SSA call-script ip out of range".to_string()) + })?, + ); + let resume_ip = b.ins().iconst( + types::I64, + i64::try_from(resume_ip).map_err(|_| { + VmError::JitNative("SSA call-script resume ip out of range".to_string()) + })?, + ); + let call = b.ins().call_indirect( + helper_refs.enter_call_script_ref, + helper_ptr, + &[ + vm_ptr, + prototype_id, + argc, + call_ip, + resume_ip, + inherited_state_ptr, + ], + ); + Ok(b.inst_results(call)[0]) + } SsaExitAction::TraceExit { allow_link_handoff } => { if allow_link_handoff { let helper_ptr = @@ -4568,7 +4670,7 @@ fn lower_ssa_exit_block( let block = match action { SsaExitAction::TraceExit { .. } => spec.trace_exit_block, SsaExitAction::Return => spec.halted_block, - SsaExitAction::CallValue { .. } => spec + SsaExitAction::CallValue { .. } | SsaExitAction::CallScript { .. } => spec .call_value_block .ok_or_else(|| VmError::JitNative("SSA call-value exit block missing".to_string()))?, SsaExitAction::InterruptYield => spec diff --git a/src/vm/jit/recorder.rs b/src/vm/jit/recorder.rs index 769d14da..c2b35c96 100644 --- a/src/vm/jit/recorder.rs +++ b/src/vm/jit/recorder.rs @@ -1,16 +1,21 @@ use std::fmt; +use std::sync::Arc; use crate::builtins::BuiltinFunction; +use crate::bytecode::CallableValue; use crate::compiler::TypeSchema; use crate::vm::{OpCode, Program, Value, ValueType, checked_int_div}; use super::JitTraceTerminal; use super::builtin_spec::{self, InputRepr, OutputKind}; use super::deopt::materialize_ssa_values; -use super::inline::{InlineCandidate, InlineRejectReason, classify_static_inline_candidate}; +use super::inline::{ + InlineCandidate, InlineRejectReason, classify_direct_inline_candidate, + classify_static_inline_candidate, +}; use super::ir::{ - SsaBranchTarget, SsaInstKind, SsaMaterialization, SsaTerminator, SsaTrace, SsaTraceBuilder, - SsaValue, SsaValueId, SsaValueRepr, VirtualFrameSnapshot, + SsaBlockId, SsaBranchTarget, SsaInstKind, SsaMaterialization, SsaTerminator, SsaTrace, + SsaTraceBuilder, SsaValue, SsaValueId, SsaValueRepr, VirtualFrameSnapshot, }; pub(super) const MAX_PROFITABLE_FRAME_LOCALS: usize = 64; @@ -212,11 +217,14 @@ impl AnalysisFrame { entry_stack_depth: usize, local_count: usize, entry_local_types: Option<&[ValueType]>, + entry_callable_prototypes: Option<&[Option]>, ) -> Self { Self { stack: vec![ValueInfo::tagged(); entry_stack_depth], locals: (0..local_count) - .map(|local| entry_local_info(program, local, entry_local_types)) + .map(|local| { + entry_local_info(program, local, entry_local_types, entry_callable_prototypes) + }) .collect(), } } @@ -250,10 +258,31 @@ fn entry_local_info( program: &Program, local: usize, entry_local_types: Option<&[ValueType]>, + entry_callable_prototypes: Option<&[Option]>, ) -> ValueInfo { let known_type = entry_local_types .and_then(|types| types.get(local)) .copied() + .or_else(|| { + // The runtime observed a callable in this slot at trace entry: + // mirror `enter_script_frame`'s inheritance of callable-valued + // caller locals at the same slot index. + entry_callable_prototypes + .and_then(|prototypes| prototypes.get(local)) + .copied() + .flatten() + .map(|_| ValueType::Callable) + }) + .or_else(|| { + // Root callable binding slots always hold environment-free + // callables at frame entry: mirror `enter_script_frame`'s fresh + // binding re-initialization even for programs without a type map. + program + .root_callable_bindings + .iter() + .any(|binding| usize::from(binding.local_slot) == local) + .then_some(ValueType::Callable) + }) .or_else(|| { program .type_map @@ -265,6 +294,164 @@ fn entry_local_info( known_type.map_or_else(ValueInfo::tagged, ValueInfo::tagged_typed) } +/// Build the callee-local SSA state for an inline frame, mirroring the +/// interpreter's `enter_script_frame` initialization: +/// +/// 1. every root callable binding slot is freshly bound to an +/// environment-free callable of the binding's prototype (never copied +/// from the caller's current slot value); +/// 2. every remaining callable-valued caller local is inherited at the same +/// slot index; +/// 3. a root binding outside the callee frame rejects the trace, matching +/// the interpreter's `InvalidFrameState` instead of silently skipping. +/// +/// The second element of the returned pair lists the slots inherited from +/// the caller frame (step 2), so the caller can record entry guards for +/// callable-valued inherited locals. +fn init_inline_callee_locals( + builder: &mut SsaTraceBuilder, + current_block: SsaBlockId, + ip: usize, + program: &Program, + frame_local_count: usize, + frame: &SymbolicFrame, +) -> Result<(Vec, Vec), TraceRecordError> { + let null = builder + .append_value_inst( + current_block, + ip, + SsaValueRepr::Tagged, + SsaInstKind::Constant(Value::Null), + ) + .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; + let null = SymbolicValue { + value: null, + info: ValueInfo::tagged_typed(ValueType::Null), + }; + let mut callee_locals = vec![null; frame_local_count]; + let mut binding_slots = Vec::with_capacity(program.root_callable_bindings.len()); + for binding in &program.root_callable_bindings { + let slot = usize::from(binding.local_slot); + if slot >= callee_locals.len() { + return Err(TraceRecordError::UnsupportedTrace( + "root callable binding is outside the script frame".to_string(), + )); + } + binding_slots.push(slot); + let kind = program + .callable_prototypes + .get(binding.prototype_id as usize) + .map(|prototype| prototype.kind) + .ok_or(TraceRecordError::UnsupportedTrace( + "root callable binding references an unknown prototype".to_string(), + ))?; + let fresh = builder + .append_value_inst( + current_block, + ip, + SsaValueRepr::Tagged, + SsaInstKind::Constant(Value::Callable(Arc::new(CallableValue { + prototype_id: binding.prototype_id, + kind, + env: None, + }))), + ) + .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; + callee_locals[slot] = SymbolicValue { + value: fresh, + info: ValueInfo::tagged_typed(ValueType::Callable), + }; + } + let mut inherited_callable_slots = Vec::new(); + for (slot, local) in frame + .locals + .iter() + .copied() + .enumerate() + .take(callee_locals.len()) + { + if local.info.known_type == Some(ValueType::Callable) && !binding_slots.contains(&slot) { + callee_locals[slot] = local; + inherited_callable_slots.push(slot); + } + } + Ok((callee_locals, inherited_callable_slots)) +} + +/// Record entry guards for callable-valued caller locals inherited into an +/// inline callee frame. +/// +/// The interpreter's `enter_script_frame` copies every callable-valued +/// caller local into the callee frame at the same slot index, and the +/// inline simulation mirrors that inheritance. The callee can specialize on +/// the inherited value's recorded type (for example a folded `typeof`), so +/// when the callable type comes from the trace-entry observation and the +/// caller slot was not rewritten on the recorded path, the trace must treat +/// the observed prototype as an entry contract: cache lookup then rejects +/// the trace after an interpreter handoff rewrote the slot, and the +/// loop-header guard check rejects native loops that rewrite it. +fn record_inherited_callable_guards( + entry_callable_guards: &mut Vec<(u8, u32)>, + entry_callable_prototypes: Option<&[Option]>, + frame: &SymbolicFrame, + inherited_callable_slots: &[usize], +) { + for &slot in inherited_callable_slots { + let Some(prototype_id) = entry_callable_prototypes + .and_then(|prototypes| prototypes.get(slot)) + .copied() + .flatten() + else { + continue; + }; + if frame.dirty_locals.get(slot).copied().unwrap_or(false) { + // The recorded path wrote the slot before the call site, so the + // runtime value is the trace's own write and cannot drift from + // the recorded type. + continue; + } + let entry_guard = (slot as u8, prototype_id); + if !entry_callable_guards.contains(&entry_guard) { + entry_callable_guards.push(entry_guard); + } + } +} + +/// Type-only twin of [`init_inline_callee_locals`] for the loop-header +/// analysis pass, which tracks `ValueInfo` without SSA values. Out-of-frame +/// root bindings are skipped here (the SSA build rejects them); the analysis +/// must stay conservative so its own checks (for example mutated inline +/// callable sources) keep firing. +fn analysis_inline_callee_locals( + program: &Program, + frame_local_count: usize, + frame: &AnalysisFrame, +) -> Vec { + let null = ValueInfo::tagged_typed(ValueType::Null); + let mut callee_locals = vec![null; frame_local_count]; + let mut binding_slots = Vec::with_capacity(program.root_callable_bindings.len()); + for binding in &program.root_callable_bindings { + let slot = usize::from(binding.local_slot); + if slot >= callee_locals.len() { + continue; + } + binding_slots.push(slot); + callee_locals[slot] = ValueInfo::tagged_typed(ValueType::Callable); + } + for (slot, local) in frame + .locals + .iter() + .copied() + .enumerate() + .take(callee_locals.len()) + { + if local.known_type == Some(ValueType::Callable) && !binding_slots.contains(&slot) { + callee_locals[slot] = local; + } + } + callee_locals +} + #[derive(Clone, Copy, Debug, PartialEq)] struct SymbolicValue { value: SsaValue, @@ -553,6 +740,12 @@ enum DecodedOp { argc: u8, resume_ip: usize, }, + CallScript { + ip: usize, + prototype_id: u32, + argc: u8, + resume_ip: usize, + }, } impl DecodedOp { @@ -572,7 +765,8 @@ impl DecodedOp { | Self::Brfalse { ip, .. } | Self::Br { ip, .. } | Self::Call { ip, .. } - | Self::CallValue { ip, .. } => ip, + | Self::CallValue { ip, .. } + | Self::CallScript { ip, .. } => ip, } } @@ -599,7 +793,8 @@ impl DecodedOp { | Self::Dup { .. } | Self::Br { .. } | Self::Call { .. } - | Self::CallValue { .. } => false, + | Self::CallValue { .. } + | Self::CallScript { .. } => false, Self::Stloc { .. } | Self::Neg { .. } | Self::Not { .. } @@ -898,6 +1093,19 @@ impl<'a> TraceCursor<'a> { argc, resume_ip: self.ip, } + } else if opcode == OpCode::CallScript as u8 { + self.recorded_ops += 1; + let prototype_id = read_u32(&self.program.code, &mut self.ip).ok_or( + TraceRecordError::InvalidImmediate("callscript prototype id"), + )?; + let argc = read_u8(&self.program.code, &mut self.ip) + .ok_or(TraceRecordError::InvalidImmediate("callscript argc"))?; + DecodedOp::CallScript { + ip: instr_ip, + prototype_id, + argc, + resume_ip: self.ip, + } } else { return Err(TraceRecordError::UnsupportedOpcode(opcode)); }; @@ -951,6 +1159,7 @@ pub(crate) fn record_trace_with_local_count( entry_stack_depth, local_count, entry_local_types, + entry_callable_prototypes, max_trace_len, non_yielding_host_imports, )?; @@ -975,7 +1184,12 @@ pub(crate) fn record_trace_with_local_count( .append_param(entry, SsaValueRepr::Tagged, format!("local{local}")) .map(|value| SymbolicValue { value, - info: entry_local_info(program, local, entry_local_types), + info: entry_local_info( + program, + local, + entry_local_types, + entry_callable_prototypes, + ), }) .map_err(|err| TraceRecordError::InvalidIr(err.to_string())) }) @@ -1563,25 +1777,20 @@ pub(crate) fn record_trace_with_local_count( let mut operands = frame.stack.split_off(operand_base); let _callable = operands.remove(0); let prototype = &program.callable_prototypes[candidate.prototype_id as usize]; - let null = builder - .append_value_inst( - current_block, - ip, - SsaValueRepr::Tagged, - SsaInstKind::Constant(Value::Null), - ) - .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; - let null = SymbolicValue { - value: null, - info: ValueInfo::tagged_typed(ValueType::Null), - }; - let mut callee_locals = vec![null; prototype.frame_local_count]; - for binding in &program.root_callable_bindings { - let slot = usize::from(binding.local_slot); - if slot < callee_locals.len() && slot < frame.locals.len() { - callee_locals[slot] = frame.locals[slot]; - } - } + let (mut callee_locals, inherited_callable_slots) = init_inline_callee_locals( + &mut builder, + current_block, + ip, + program, + prototype.frame_local_count, + &frame, + )?; + record_inherited_callable_guards( + &mut entry_callable_guards, + entry_callable_prototypes, + &frame, + &inherited_callable_slots, + ); for (slot, mut argument) in candidate.parameter_slots.iter().zip(operands) { if argument.info.repr == SsaValueRepr::Tagged { let cloned = builder @@ -1636,6 +1845,140 @@ pub(crate) fn record_trace_with_local_count( terminal = Some(JitTraceTerminal::CallValue); break; } + DecodedOp::CallScript { + ip, + prototype_id, + argc, + resume_ip, + } => { + if frame.stack.len() < usize::from(argc) { + return Err(TraceRecordError::StackUnderflow); + } + let caller_prototype_id = (caller_frame_key != crate::vm::native::ROOT_FRAME_KEY) + .then_some(caller_frame_key as u32); + // The prototype identity is static: no callable local is + // loaded and no polymorphic entry guard is required. + let candidate = classify_direct_inline_candidate( + program, + caller_frame_key, + caller_prototype_id, + prototype_id, + argc, + max_trace_len.saturating_sub(cursor.recorded_ops), + ); + let inline_reject_reason = candidate.as_ref().err().copied(); + if inline_frame.is_none() + && let Ok(candidate) = candidate + { + let prototype = &program.callable_prototypes[prototype_id as usize]; + let argument_start = frame.stack.len() - usize::from(argc); + let schema_guard = append_inline_argument_schema_guards( + &mut builder, + current_block, + ip, + &frame.stack[argument_start..], + prototype.schema.as_ref(), + )?; + if let Some(schema_guard) = schema_guard { + let schema_exit = + add_symbolic_exit(&mut builder, ip, &frame, inline_frame.as_ref()); + let (guarded_block, guarded_frame, guard_args) = + continue_with_inline_frame( + &mut builder, + &frame, + &mut inline_frame, + "inline_callable_schema", + )?; + builder + .set_terminator( + current_block, + SsaTerminator::BranchBool { + condition: schema_guard, + if_true: SsaBranchTarget::Block { + target: guarded_block, + args: guard_args, + }, + if_false: SsaBranchTarget::Exit(schema_exit), + }, + ) + .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; + current_block = guarded_block; + frame = guarded_frame; + } + + // `CallScript` pushes no callable operand: the arguments + // are exactly the top `argc` stack values. + let operand_base = frame.stack.len() - usize::from(argc); + let operands = frame.stack.split_off(operand_base); + let (mut callee_locals, inherited_callable_slots) = init_inline_callee_locals( + &mut builder, + current_block, + ip, + program, + prototype.frame_local_count, + &frame, + )?; + record_inherited_callable_guards( + &mut entry_callable_guards, + entry_callable_prototypes, + &frame, + &inherited_callable_slots, + ); + for (slot, mut argument) in candidate.parameter_slots.iter().zip(operands) { + if argument.info.repr == SsaValueRepr::Tagged { + let cloned = builder + .append_value_inst( + current_block, + ip, + SsaValueRepr::Tagged, + SsaInstKind::CloneTagged { + input: argument.value.id, + }, + ) + .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; + argument.value = cloned; + } + callee_locals[usize::from(*slot)] = argument; + } + op_names.push(format!("inline_call:{prototype_id}")); + let caller = std::mem::replace( + &mut frame, + SymbolicFrame::new(Vec::new(), callee_locals), + ); + inline_frame = Some(InlineRecorderFrame { + candidate: candidate.clone(), + call_ip: ip, + return_ip: resume_ip, + caller, + }); + cursor.jump_to(candidate.entry_ip)?; + has_call = true; + continue; + } + if let Some(reason) = inline_reject_reason { + op_names.push(format!("inline_reject:{reason:?}")); + } else if inline_frame.is_some() { + op_names.push("inline_reject:NestedCallable".to_string()); + } + op_names.push("call_script".to_string()); + let exit = add_symbolic_exit(&mut builder, ip, &frame, inline_frame.as_ref()); + builder + .set_terminator( + current_block, + SsaTerminator::CallScript { + prototype_id, + argc, + call_ip: ip, + resume_ip, + exit, + }, + ) + .map_err(|err| TraceRecordError::InvalidIr(err.to_string()))?; + has_call = true; + has_yielding_call = true; + terminal = Some(JitTraceTerminal::CallScript); + break; + } DecodedOp::Call { ip, index, @@ -1754,7 +2097,11 @@ pub(crate) fn record_trace_with_local_count( } let terminal = terminal.ok_or(TraceRecordError::MissingTerminal)?; - if loop_header_plan.is_some() + // A native loop re-iterates the recorded body without a cache lookup, so + // a guarded callable source local must stay untouched by the recorded + // path. This applies to every `LoopBack` trace, including loop-header + // plans the analysis pass declined to build. + if matches!(terminal, JitTraceTerminal::LoopBack) && entry_callable_guards.iter().any(|(local, _)| { let local = usize::from(*local); frame.dirty_locals.get(local).copied().unwrap_or(false) @@ -1791,11 +2138,18 @@ fn infer_loop_header_plan( entry_stack_depth: usize, local_count: usize, entry_local_types: Option<&[ValueType]>, + entry_callable_prototypes: Option<&[Option]>, max_trace_len: usize, non_yielding_host_imports: &[bool], ) -> Result, TraceRecordError> { let mut cursor = TraceCursor::new(program, root_ip, max_trace_len); - let mut frame = AnalysisFrame::new(program, entry_stack_depth, local_count, entry_local_types); + let mut frame = AnalysisFrame::new( + program, + entry_stack_depth, + local_count, + entry_local_types, + entry_callable_prototypes, + ); let mut entry_use = vec![EntryUseState::Untouched; local_count]; let mut local_written = vec![false; local_count]; let mut inline_frame: Option<(AnalysisFrame, usize)> = None; @@ -2011,14 +2365,47 @@ fn infer_loop_header_plan( let operand_base = frame.stack.len() - usize::from(argc) - 1; let mut operands = frame.stack.split_off(operand_base); let _callable = operands.remove(0); - let null = ValueInfo::tagged_typed(ValueType::Null); - let mut callee_locals = vec![null; prototype.frame_local_count]; - for binding in &program.root_callable_bindings { - let slot = usize::from(binding.local_slot); - if slot < callee_locals.len() && slot < frame.locals.len() { - callee_locals[slot] = frame.locals[slot]; - } + let mut callee_locals = + analysis_inline_callee_locals(program, prototype.frame_local_count, &frame); + for (slot, argument) in candidate.parameter_slots.iter().zip(operands) { + callee_locals[usize::from(*slot)] = argument; + } + let caller = std::mem::replace( + &mut frame, + AnalysisFrame { + stack: Vec::new(), + locals: callee_locals, + }, + ); + inline_frame = Some((caller, resume_ip)); + cursor.jump_to(candidate.entry_ip)?; + } + DecodedOp::CallScript { + prototype_id, + argc, + resume_ip, + .. + } => { + if inline_frame.is_some() || frame.stack.len() < usize::from(argc) { + return Ok(None); } + let caller_prototype_id = (caller_frame_key != crate::vm::native::ROOT_FRAME_KEY) + .then_some(caller_frame_key as u32); + let Ok(candidate) = classify_direct_inline_candidate( + program, + caller_frame_key, + caller_prototype_id, + prototype_id, + argc, + max_trace_len.saturating_sub(cursor.recorded_ops), + ) else { + return Ok(None); + }; + let prototype = &program.callable_prototypes[prototype_id as usize]; + let operand_base = frame.stack.len() - usize::from(argc); + let operands = frame.stack.split_off(operand_base); + let mut callee_locals = + analysis_inline_callee_locals(program, prototype.frame_local_count, &frame); for (slot, argument) in candidate.parameter_slots.iter().zip(operands) { callee_locals[usize::from(*slot)] = argument; } @@ -5036,7 +5423,11 @@ mod tests { kind: CallableKind::FunctionItem, target: CallableTarget::ScriptFunction(0), arity: 0, - frame_local_count: 1, + // The callee frame must span both root binding slots + // (0 and 1); a smaller frame would be rejected by the + // interpreter's `enter_script_frame` before the + // mutation check this test exercises. + frame_local_count: 2, parameter_slots: Vec::new(), capture_source_slots: Vec::new(), capture_slots: Vec::new(), @@ -5274,4 +5665,109 @@ mod tests { .all(|block| !matches!(block.terminator, Some(SsaTerminator::CallValue { .. }))) ); } + + #[test] + fn rejects_inline_callee_with_root_binding_outside_frame() { + // Root: i = 0; loop: i = i + 1; callscript 1 0; i < 2; brfalse end; + // br loop; end: ldc 0; ret. Prototype 1 (the inlinable callee) has a + // frame_local_count of 2 while the root binding for prototype 0 + // lives at slot 3: the interpreter's `enter_script_frame` raises + // `InvalidFrameState`, so the recorder must reject the trace instead + // of silently skipping the out-of-frame binding. + let mut bc = BytecodeBuilder::new(); + bc.ldc(0); + bc.stloc(0); + let root_ip = bc.position(); + bc.ldloc(0); + bc.ldc(1); + bc.add(); + bc.stloc(0); + bc.call_script(1, 0); + bc.ldloc(0); + bc.ldc(2); + bc.clt(); + let branch_ip = bc.position(); + bc.brfalse(0); + let end_label = bc.position(); + bc.ldc(0); + bc.ret(); + let br_ip = bc.position(); + bc.br(0); + let mut code = bc.finish(); + patch_branch_target(&mut code, branch_ip, end_label); + patch_branch_target(&mut code, br_ip, root_ip); + let callee_entry = code.len() as u32; + code.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Ret as u8]); + let callee_end = code.len() as u32; + + let program = Program::new(vec![Value::Int(0), Value::Int(1), Value::Int(2)], code) + .with_local_count(4) + .with_callable_metadata( + vec![ + ScriptFunction { + entry_ip: callee_entry, + end_ip: callee_end, + }, + ScriptFunction { + entry_ip: callee_entry, + end_ip: callee_end, + }, + ], + vec![ + CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 1, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }, + CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(1), + arity: 0, + frame_local_count: 2, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }, + ], + vec![ + FunctionRegion { + start_ip: 0, + end_ip: callee_entry, + prototype_id: None, + }, + FunctionRegion { + start_ip: callee_entry, + end_ip: callee_end, + prototype_id: Some(0), + }, + FunctionRegion { + start_ip: callee_entry, + end_ip: callee_end, + prototype_id: Some(1), + }, + ], + vec![RootCallableBinding { + local_slot: 3, + prototype_id: 0, + }], + ); + + let error = record_trace(&program, root_ip as usize, 0, 64, &[]) + .expect_err("out-of-frame root binding must reject the trace, not silently skip"); + assert!(matches!( + error, + TraceRecordError::UnsupportedTrace(detail) + if detail == "root callable binding is outside the script frame" + )); + } } diff --git a/src/vm/jit/region.rs b/src/vm/jit/region.rs index aea8c35b..981bf640 100644 --- a/src/vm/jit/region.rs +++ b/src/vm/jit/region.rs @@ -447,7 +447,8 @@ fn offset_terminator( } SsaTerminator::Exit { exit } | SsaTerminator::Return { exit } - | SsaTerminator::CallValue { exit, .. } => { + | SsaTerminator::CallValue { exit, .. } + | SsaTerminator::CallScript { exit, .. } => { *exit = offset_exit_id(*exit, exit_offset)?; } } diff --git a/src/vm/jit/runtime.rs b/src/vm/jit/runtime.rs index 751ef231..e4d946a0 100644 --- a/src/vm/jit/runtime.rs +++ b/src/vm/jit/runtime.rs @@ -257,20 +257,27 @@ pub(crate) extern "C" fn pd_vm_native_resume_linked_trace(vm: *mut Vm) -> i32 { return native::STATUS_ERROR; }; - if vm_ref.jit_native_link_dispatch_depth > 0 { + if vm_ref.engine.jit_native_link_dispatch_depth > 0 { return native::STATUS_TRACE_EXIT; } - vm_ref.jit_native_link_dispatch_depth = vm_ref.jit_native_link_dispatch_depth.saturating_add(1); + vm_ref.engine.jit_native_link_dispatch_depth = vm_ref + .engine + .jit_native_link_dispatch_depth + .saturating_add(1); match vm_ref.continue_linked_native_trace_from_exit() { Ok(status) => { - vm_ref.jit_native_link_dispatch_depth = - vm_ref.jit_native_link_dispatch_depth.saturating_sub(1); + vm_ref.engine.jit_native_link_dispatch_depth = vm_ref + .engine + .jit_native_link_dispatch_depth + .saturating_sub(1); status } Err(err) => { - vm_ref.jit_native_link_dispatch_depth = - vm_ref.jit_native_link_dispatch_depth.saturating_sub(1); + vm_ref.engine.jit_native_link_dispatch_depth = vm_ref + .engine + .jit_native_link_dispatch_depth + .saturating_sub(1); native::store_bridge_error(err); native::STATUS_ERROR } @@ -283,9 +290,9 @@ impl Vm { return None; } let entry_callable_prototypes = self.active_local_callable_prototypes(); - self.jit.compiled_trace_for_entry_with_callables( + self.engine.jit.compiled_trace_for_entry_with_callables( self.active_frame_key(), - self.ip, + self.instance.ip, self.active_operand_stack_len(), entry_callable_prototypes.as_deref(), ) @@ -299,21 +306,21 @@ impl Vm { all(target_arch = "aarch64", any(target_os = "linux", target_os = "macos")) ))] fn continue_linked_native_trace_from_exit(&mut self) -> VmResult { - self.jit_trace_exit_count = self.jit_trace_exit_count.saturating_add(1); + self.engine.jit_trace_exit_count = self.engine.jit_trace_exit_count.saturating_add(1); let mut current_trace_id = { - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); if next_trace_id.is_none() && !self.active_frame_has_shared_capture_cells() - && !self.jit.callable_frame_is_blocked(frame_key) + && !self.engine.jit.callable_frame_is_blocked(frame_key) { let entry_local_types = (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - next_trace_id = self.jit.observe_exit_entry_with_local_types( + next_trace_id = self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -352,15 +359,16 @@ impl Vm { loop { native::clear_bridge_error(); - let region_edges_before = self.jit_native_region_edge_count; - let direct_links_before = self.jit_native_direct_link_count; + let region_edges_before = self.engine.jit_native_region_edge_count; + let direct_links_before = self.engine.jit_native_direct_link_count; let status = unsafe { entry(self as *mut Vm) }; - self.native_trace_exec_count = self.native_trace_exec_count.saturating_add(1); + self.engine.native_trace_exec_count = + self.engine.native_trace_exec_count.saturating_add(1); if !is_region - && self.jit_native_active_direct_trace_id != usize::MAX - && self.jit_native_active_direct_trace_id != current_trace_id + && self.engine.jit_native_active_direct_trace_id != usize::MAX + && self.engine.jit_native_active_direct_trace_id != current_trace_id { - current_trace_id = self.jit_native_active_direct_trace_id; + current_trace_id = self.engine.jit_native_active_direct_trace_id; let state = self.native_trace_state(current_trace_id)?; entry = state.0; root_ip = state.1; @@ -371,13 +379,15 @@ impl Vm { } self.record_native_direct_escape(status, direct_links_before); if is_region { - self.jit_native_region_entry_count = - self.jit_native_region_entry_count.saturating_add(1); - if self.jit_native_region_edge_count > region_edges_before { - self.jit.record_native_region_progress(current_trace_id); + self.engine.jit_native_region_entry_count = + self.engine.jit_native_region_entry_count.saturating_add(1); + if self.engine.jit_native_region_edge_count > region_edges_before { + self.engine + .jit + .record_native_region_progress(current_trace_id); } } - self.jit.mark_trace_executed(current_trace_id); + self.engine.jit.mark_trace_executed(current_trace_id); let mut trace_exit_key = None; let mut instruction_failure_exit = false; let status = if let Some(exit_id) = native::decode_jit_trace_exit_status(status) { @@ -393,8 +403,9 @@ impl Vm { exit_id: SsaExitId::new(exit_id), } }; - instruction_failure_exit = self.jit.trace_exit_is_instruction_failure(key); - self.jit + instruction_failure_exit = self.engine.jit.trace_exit_is_instruction_failure(key); + self.engine + .jit .record_trace_exit(key) .map_err(|err| VmError::JitNative(err.message()))?; trace_exit_key = Some(key); @@ -445,37 +456,39 @@ impl Vm { return Ok(native::STATUS_LINKED_CONTINUE); } native::STATUS_TRACE_EXIT => { - self.jit_trace_exit_count = self.jit_trace_exit_count.saturating_add(1); + self.engine.jit_trace_exit_count = + self.engine.jit_trace_exit_count.saturating_add(1); if instruction_failure_exit { return Ok(native::STATUS_LINKED_CONTINUE); } if !has_yielding_call && terminal == JitTraceTerminal::LoopBack - && self.ip == root_ip + && self.instance.ip == root_ip { - self.jit.record_native_loop_back(current_trace_id); - self.jit_native_loop_back_count = - self.jit_native_loop_back_count.saturating_add(1); + self.engine.jit.record_native_loop_back(current_trace_id); + self.engine.jit_native_loop_back_count = + self.engine.jit_native_loop_back_count.saturating_add(1); continue; } - if self.jit.record_native_side_exit(current_trace_id) - && !self.jit_native_direct_links_enabled + if self.engine.jit.record_native_side_exit(current_trace_id) + && !self.engine.jit_native_direct_links_enabled { self.block_jit_callable_frame(current_trace_id); return Ok(native::STATUS_LINKED_CONTINUE); } if !has_yielding_call && !self.active_frame_has_shared_capture_cells() { - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); - if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key) + if next_trace_id.is_none() + && !self.engine.jit.callable_frame_is_blocked(frame_key) { let entry_local_types = (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - next_trace_id = self.jit.observe_exit_entry_with_local_types( + next_trace_id = self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -534,19 +547,19 @@ impl Vm { } fn active_native_interrupt_settings(&self) -> Option { - match self.interrupt_mode { + match self.run_ctx.interrupt_mode { super::super::InterruptMode::None => None, super::super::InterruptMode::Fuel => Some(native::NativeInterruptSettings::fuel( - self.fuel_check_interval, + self.run_ctx.fuel_check_interval, )), super::super::InterruptMode::Epoch => Some(native::NativeInterruptSettings::epoch( - self.fuel_check_interval, + self.run_ctx.fuel_check_interval, )), } } fn clear_native_direct_links(&self) { - for native in self.native_traces.iter().flatten() { + for native in self.engine.native_traces.iter().flatten() { for slot in native.direct_slots.values() { slot.clear(); } @@ -554,15 +567,16 @@ impl Vm { } fn record_native_direct_escape(&mut self, _status: i32, direct_links_before: u64) { - if !self.jit_native_direct_links_enabled - || self.jit_native_direct_link_count == direct_links_before + if !self.engine.jit_native_direct_links_enabled + || self.engine.jit_native_direct_link_count == direct_links_before { return; } - self.jit_native_direct_escape_streak = 0; - if self.jit_native_active_direct_trace_id != usize::MAX { - self.jit - .record_native_loop_back(self.jit_native_active_direct_trace_id); + self.engine.jit_native_direct_escape_streak = 0; + if self.engine.jit_native_active_direct_trace_id != usize::MAX { + self.engine + .jit + .record_native_loop_back(self.engine.jit_native_active_direct_trace_id); } } @@ -571,7 +585,9 @@ impl Vm { key: TraceExitKey, child_trace_id: usize, ) -> VmResult<()> { - if !self.jit_native_direct_links_enabled || self.jit_native_direct_region_fallback { + if !self.engine.jit_native_direct_links_enabled + || self.engine.jit_native_direct_region_fallback + { return Ok(()); } self.publish_native_direct_slot(key.parent_trace_id, key.exit_id.raw(), child_trace_id) @@ -590,15 +606,21 @@ impl Vm { slot_id: u32, child_trace_id: usize, ) -> VmResult<()> { - if self.jit.trace_has_entry_callable_guards(child_trace_id) { + if self + .engine + .jit + .trace_has_entry_callable_guards(child_trace_id) + { return Ok(()); } - if !self.jit_native_direct_cross_frame_enabled { + if !self.engine.jit_native_direct_cross_frame_enabled { let parent_frame_key = self + .engine .jit .trace_clone(parent_trace_id) .map(|trace| trace.frame_key); let child_frame_key = self + .engine .jit .trace_clone(child_trace_id) .map(|trace| trace.frame_key); @@ -608,6 +630,7 @@ impl Vm { } self.ensure_native_trace(child_trace_id, native::NativeCompileProfile::Jit)?; let child_entry = self + .engine .native_traces .get(child_trace_id) .and_then(Option::as_ref) @@ -616,6 +639,7 @@ impl Vm { })? .tail_entry as *const u8; let Some(slot) = self + .engine .native_traces .get(parent_trace_id) .and_then(Option::as_ref) @@ -652,28 +676,34 @@ impl Vm { all(target_arch = "aarch64", any(target_os = "linux", target_os = "macos")) ))] fn maybe_publish_native_region(&mut self, key: TraceExitKey, child_trace_id: usize) { - if self.jit_native_direct_links_enabled && !self.jit_native_direct_region_fallback { + if self.engine.jit_native_direct_links_enabled + && !self.engine.jit_native_direct_region_fallback + { return; } if self + .engine .jit .trace_has_entry_callable_guards(key.parent_trace_id) - || self.jit.trace_has_entry_callable_guards(child_trace_id) + || self + .engine + .jit + .trace_has_entry_callable_guards(child_trace_id) { return; } - let Some(candidate) = self.jit.region_candidate(key, child_trace_id) else { + let Some(candidate) = self.engine.jit.region_candidate(key, child_trace_id) else { return; }; - if candidate.generation != self.jit.region_generation() { + if candidate.generation != self.engine.jit.region_generation() { return; } - let Some(parent) = self.jit.trace_clone(key.parent_trace_id) else { - self.jit.record_region_compile_failure(&candidate); + let Some(parent) = self.engine.jit.trace_clone(key.parent_trace_id) else { + self.engine.jit.record_region_compile_failure(&candidate); return; }; - let Some(child) = self.jit.trace_clone(child_trace_id) else { - self.jit.record_region_compile_failure(&candidate); + let Some(child) = self.engine.jit.trace_clone(child_trace_id) else { + self.engine.jit.record_region_compile_failure(&candidate); return; }; let back_import = scalar_cycle_import(&candidate.import) @@ -684,7 +714,8 @@ impl Vm { .iter() .filter(|exit| exit.exit_ip == parent.root_ip) .find_map(|exit| { - self.jit + self.engine + .jit .side_trace_import(child.id, exit.id, parent.id) .ok() }) @@ -699,7 +730,7 @@ impl Vm { ) { Ok(fused) => fused, Err(_) => { - self.jit.record_region_compile_failure(&candidate); + self.engine.jit.record_region_compile_failure(&candidate); return; } }; @@ -713,13 +744,14 @@ impl Vm { compile_profile, drop_contract_events_enabled, ); - self.jit_native_region_compile_time_ns = self + self.engine.jit_native_region_compile_time_ns = self + .engine .jit_native_region_compile_time_ns .saturating_add(elapsed_ns(compile_started)); let compiled = match compile_result { Ok(compiled) => compiled, Err(_) => { - self.jit.record_region_compile_failure(&candidate); + self.engine.jit.record_region_compile_failure(&candidate); return; } }; @@ -741,37 +773,38 @@ impl Vm { exit_keys: Arc::new(fused.exit_keys), }; let Some(parent_native) = self + .engine .native_traces .get_mut(key.parent_trace_id) .and_then(Option::as_mut) else { - self.jit.record_region_compile_failure(&candidate); + self.engine.jit.record_region_compile_failure(&candidate); return; }; - if !self.jit.publish_region(&candidate) { + if !self.engine.jit.publish_region(&candidate) { return; } parent_native.region = Some(region); } fn clear_native_region_owners(&mut self) { - for native in self.native_traces.iter_mut().flatten() { + for native in self.engine.native_traces.iter_mut().flatten() { native.region = None; } } pub(crate) fn disconnect_native_regions(&mut self) { - self.jit.invalidate_regions(); + self.engine.jit.invalidate_regions(); self.clear_native_region_owners(); } fn block_jit_trace(&mut self, trace_id: usize) { - self.jit.block_trace(trace_id); + self.engine.jit.block_trace(trace_id); self.clear_native_region_owners(); } fn block_jit_callable_frame(&mut self, trace_id: usize) { - self.jit.block_callable_frame(trace_id); + self.engine.jit.block_callable_frame(trace_id); self.clear_native_region_owners(); } @@ -780,26 +813,26 @@ impl Vm { self.ensure_program_cache_key(); } self.clear_native_direct_links(); - self.native_traces.clear(); - self.native_trace_exec_count = 0; - self.jit_native_region_entry_count = 0; - self.jit_native_region_edge_count = 0; - self.jit_native_direct_link_count = 0; - self.jit_native_active_direct_trace_id = usize::MAX; - self.jit_native_direct_escape_streak = 0; - self.jit_native_direct_region_fallback = false; - self.jit_native_compile_time_ns = 0; - self.jit_native_region_compile_time_ns = 0; - self.jit_trace_exit_count = 0; - self.jit_native_loop_back_count = 0; - self.jit_native_link_handoff_count = 0; - self.jit_native_link_dispatch_depth = 0; - self.jit_helper_fallback_count = 0; - self.jit.set_config(config); + self.engine.native_traces.clear(); + self.engine.native_trace_exec_count = 0; + self.engine.jit_native_region_entry_count = 0; + self.engine.jit_native_region_edge_count = 0; + self.engine.jit_native_direct_link_count = 0; + self.engine.jit_native_active_direct_trace_id = usize::MAX; + self.engine.jit_native_direct_escape_streak = 0; + self.engine.jit_native_direct_region_fallback = false; + self.engine.jit_native_compile_time_ns = 0; + self.engine.jit_native_region_compile_time_ns = 0; + self.engine.jit_trace_exit_count = 0; + self.engine.jit_native_loop_back_count = 0; + self.engine.jit_native_link_handoff_count = 0; + self.engine.jit_native_link_dispatch_depth = 0; + self.engine.jit_helper_fallback_count = 0; + self.engine.jit.set_config(config); } pub fn jit_config(&self) -> &super::JitConfig { - self.jit.config() + self.engine.jit.config() } pub fn jit_snapshot(&self) -> super::JitSnapshot { @@ -807,15 +840,16 @@ impl Vm { } pub fn jit_exit_profiles(&self) -> Vec { - self.jit.exit_profiles() + self.engine.jit.exit_profiles() } pub fn jit_call_site_profiles(&self) -> Vec { - self.jit.call_site_profiles() + self.engine.jit.call_site_profiles() } pub fn jit_native_code_bytes(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .map(|native| native.code.len()) @@ -823,7 +857,8 @@ impl Vm { } pub fn jit_native_region_code_bytes(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .filter_map(|native| native.region.as_ref()) @@ -832,11 +867,11 @@ impl Vm { } pub fn jit_native_compile_time_ns(&self) -> u64 { - self.jit_native_compile_time_ns + self.engine.jit_native_compile_time_ns } pub fn jit_native_region_compile_time_ns(&self) -> u64 { - self.jit_native_region_compile_time_ns + self.engine.jit_native_region_compile_time_ns } pub fn dump_jit_info(&self) -> String { @@ -909,15 +944,16 @@ impl Vm { ) = self.native_trace_state(current_trace_id)?; native::clear_bridge_error(); loop { - let region_edges_before = self.jit_native_region_edge_count; - let direct_links_before = self.jit_native_direct_link_count; + let region_edges_before = self.engine.jit_native_region_edge_count; + let direct_links_before = self.engine.jit_native_direct_link_count; let status = unsafe { entry(self as *mut Vm) }; - self.native_trace_exec_count = self.native_trace_exec_count.saturating_add(1); + self.engine.native_trace_exec_count = + self.engine.native_trace_exec_count.saturating_add(1); if !is_region - && self.jit_native_active_direct_trace_id != usize::MAX - && self.jit_native_active_direct_trace_id != current_trace_id + && self.engine.jit_native_active_direct_trace_id != usize::MAX + && self.engine.jit_native_active_direct_trace_id != current_trace_id { - current_trace_id = self.jit_native_active_direct_trace_id; + current_trace_id = self.engine.jit_native_active_direct_trace_id; let state = self.native_trace_state(current_trace_id)?; entry = state.0; root_ip = state.1; @@ -928,13 +964,15 @@ impl Vm { } self.record_native_direct_escape(status, direct_links_before); if is_region { - self.jit_native_region_entry_count = - self.jit_native_region_entry_count.saturating_add(1); - if self.jit_native_region_edge_count > region_edges_before { - self.jit.record_native_region_progress(current_trace_id); + self.engine.jit_native_region_entry_count = + self.engine.jit_native_region_entry_count.saturating_add(1); + if self.engine.jit_native_region_edge_count > region_edges_before { + self.engine + .jit + .record_native_region_progress(current_trace_id); } } - self.jit.mark_trace_executed(current_trace_id); + self.engine.jit.mark_trace_executed(current_trace_id); let mut trace_exit_key = None; let mut instruction_failure_exit = false; let status = if let Some(exit_id) = native::decode_jit_trace_exit_status(status) { @@ -950,8 +988,9 @@ impl Vm { exit_id: SsaExitId::new(exit_id), } }; - instruction_failure_exit = self.jit.trace_exit_is_instruction_failure(key); - self.jit + instruction_failure_exit = self.engine.jit.trace_exit_is_instruction_failure(key); + self.engine + .jit .record_trace_exit(key) .map_err(|err| VmError::JitNative(err.message()))?; trace_exit_key = Some(key); @@ -1017,13 +1056,19 @@ impl Vm { return Ok(ExecOutcome::Continue); } native::STATUS_TRACE_EXIT => { - self.jit_trace_exit_count = self.jit_trace_exit_count.saturating_add(1); + self.engine.jit_trace_exit_count = + self.engine.jit_trace_exit_count.saturating_add(1); if instruction_failure_exit { return Ok(ExecOutcome::Continue); } - if self.jit.trace_clone(current_trace_id).is_some_and(|trace| { - trace.op_names.last().map(String::as_str) == Some("callable_boundary") - }) { + if self + .engine + .jit + .trace_clone(current_trace_id) + .is_some_and(|trace| { + trace.op_names.last().map(String::as_str) == Some("callable_boundary") + }) + { self.block_jit_trace(current_trace_id); return Ok(ExecOutcome::Continue); } @@ -1031,25 +1076,26 @@ impl Vm { // calls, keep executing in native mode without bouncing through the interpreter. if !has_yielding_call && terminal == JitTraceTerminal::LoopBack - && self.ip == root_ip + && self.instance.ip == root_ip { - self.jit.record_native_loop_back(current_trace_id); - self.jit_native_loop_back_count = - self.jit_native_loop_back_count.saturating_add(1); + self.engine.jit.record_native_loop_back(current_trace_id); + self.engine.jit_native_loop_back_count = + self.engine.jit_native_loop_back_count.saturating_add(1); continue; } - if self.jit.record_native_side_exit(current_trace_id) - && !self.jit_native_direct_links_enabled + if self.engine.jit.record_native_side_exit(current_trace_id) + && !self.engine.jit_native_direct_links_enabled { self.block_jit_callable_frame(current_trace_id); return Ok(ExecOutcome::Continue); } if !has_yielding_call && !self.active_frame_has_shared_capture_cells() { - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); - if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key) + if next_trace_id.is_none() + && !self.engine.jit.callable_frame_is_blocked(frame_key) { next_trace_id = { let entry_local_types = (frame_key != ROOT_FRAME_KEY) @@ -1057,7 +1103,7 @@ impl Vm { let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - self.jit.observe_exit_entry_with_local_types( + self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -1121,17 +1167,19 @@ impl Vm { if self.active_frame_has_shared_capture_cells() { return Ok(ExecOutcome::Continue); } - let ip = self.ip; + let ip = self.instance.ip; let frame_key = self.active_frame_key(); let stack_depth = self.active_operand_stack_len(); let mut next_trace_id = self.compiled_trace_for_active_entry(); - if next_trace_id.is_none() && !self.jit.callable_frame_is_blocked(frame_key) { + if next_trace_id.is_none() + && !self.engine.jit.callable_frame_is_blocked(frame_key) + { next_trace_id = { let entry_local_types = (frame_key != ROOT_FRAME_KEY).then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - self.jit.observe_exit_entry_with_local_types( + self.engine.jit.observe_exit_entry_with_local_types( frame_key, ip, stack_depth, @@ -1186,26 +1234,31 @@ impl Vm { return Ok(ExecOutcome::Continue); } native::STATUS_YIELDED => { - self.last_yield_reason = Some(super::super::VmYieldReason::Host); + self.instance.last_yield_reason = Some(super::super::VmYieldReason::Host); return Ok(ExecOutcome::Yielded); } native::STATUS_WAITING => { - let op_id = self.waiting_host_op.map(|op| op.op_id).ok_or_else(|| { - VmError::JitNative( - "native call bridge reported waiting without a pending op".to_string(), - ) - })?; + let op_id = self + .instance + .waiting_host_op + .map(|op| op.op_id) + .ok_or_else(|| { + VmError::JitNative( + "native call bridge reported waiting without a pending op" + .to_string(), + ) + })?; return Ok(ExecOutcome::Waiting(op_id)); } native::STATUS_OUT_OF_FUEL => { - return match self.interrupt_mode { + return match self.run_ctx.interrupt_mode { super::super::InterruptMode::Fuel => Err(VmError::OutOfFuel { - needed: u64::from(self.fuel_check_interval), - remaining: self.fuel_remaining, + needed: u64::from(self.run_ctx.fuel_check_interval), + remaining: self.run_ctx.fuel_remaining, }), super::super::InterruptMode::Epoch => Err(VmError::EpochDeadlineReached { current: self.current_epoch(), - deadline: self.epoch_deadline, + deadline: self.run_ctx.epoch_deadline, }), super::super::InterruptMode::None => Err(VmError::JitNative( "native interruption checkpoint fired while interruption was disabled" @@ -1215,19 +1268,20 @@ impl Vm { } native::STATUS_ERROR => { let err = native::take_bridge_error().unwrap_or_else(|| { - let trace_meta = self.jit.trace_clone(current_trace_id).map(|trace| { - format!( - "trace_id={} root_ip={} terminal={:?} ops={}", - trace.id, - trace.root_ip, - trace.terminal, - trace.op_names.len() - ) - }); + let trace_meta = + self.engine.jit.trace_clone(current_trace_id).map(|trace| { + format!( + "trace_id={} root_ip={} terminal={:?} ops={}", + trace.id, + trace.root_ip, + trace.terminal, + trace.op_names.len() + ) + }); VmError::JitNative(format!( "jit bridge reported failure without VmError (ip={} stack_len={} {})", - self.ip, - self.stack.len(), + self.instance.ip, + self.instance.stack.len(), trace_meta.unwrap_or_else(|| "trace=".to_string()) )) }); @@ -1252,6 +1306,7 @@ impl Vm { ))] fn native_trace_state(&self, trace_id: usize) -> VmResult { let native = self + .engine .native_traces .get(trace_id) .and_then(Option::as_ref) @@ -1259,7 +1314,7 @@ impl Vm { VmError::JitNative(format!("native trace entry for id {} missing", trace_id)) })?; if let Some(region) = native.region.as_ref().filter(|region| { - self.jit.published_region().is_some_and(|published| { + self.engine.jit.published_region().is_some_and(|published| { published.generation == region.generation && published.key == region.key && published.child_trace_id == region.child_trace_id @@ -1300,10 +1355,10 @@ impl Vm { trace_id: usize, compile_profile: native::NativeCompileProfile, ) -> Option { - let native = self.native_traces.get(trace_id)?.as_ref()?; + let native = self.engine.native_traces.get(trace_id)?.as_ref()?; (native.interrupt_settings == self.active_native_interrupt_settings() && compile_profile_satisfies(native.compile_profile, compile_profile) - && native.drop_contract_events_enabled == self.drop_contract_events_enabled) + && native.drop_contract_events_enabled == self.instance.drop_contract_events_enabled) .then(|| self.native_trace_state(trace_id).ok()) .flatten() } @@ -1337,7 +1392,11 @@ impl Vm { compile_profile: native::NativeCompileProfile, interrupt_settings: Option, ) -> VmResult<()> { - if let Some(native) = self.native_traces.get(trace_id).and_then(Option::as_ref) + if let Some(native) = self + .engine + .native_traces + .get(trace_id) + .and_then(Option::as_ref) && native.interrupt_settings == interrupt_settings && compile_profile_satisfies(native.compile_profile, compile_profile) && native.drop_contract_events_enabled == self.drop_contract_events_enabled() @@ -1345,6 +1404,7 @@ impl Vm { return Ok(()); } if self + .engine .native_traces .get(trace_id) .and_then(Option::as_ref) @@ -1353,12 +1413,12 @@ impl Vm { self.disconnect_native_regions(); } self.clear_native_direct_links(); - if let Some(slot) = self.native_traces.get_mut(trace_id) { + if let Some(slot) = self.engine.native_traces.get_mut(trace_id) { *slot = None; } let program_cache_key = self.ensure_program_cache_key(); - let trace = self.jit.trace_clone(trace_id).ok_or_else(|| { + let trace = self.engine.jit.trace_clone(trace_id).ok_or_else(|| { VmError::JitNative(format!("trace {} missing for native compile", trace_id)) })?; let drop_contract_events_enabled = self.drop_contract_events_enabled(); @@ -1393,10 +1453,10 @@ impl Vm { .collect(); let mut code = cached.code.to_vec(); code.extend_from_slice(&dispatcher.code); - if self.native_traces.len() <= trace_id { - self.native_traces.resize_with(trace_id + 1, || None); + if self.engine.native_traces.len() <= trace_id { + self.engine.native_traces.resize_with(trace_id + 1, || None); } - self.native_traces[trace_id] = Some(NativeTrace { + self.engine.native_traces[trace_id] = Some(NativeTrace { _keepalive: cached.keepalive, _direct_keepalives: direct_keepalives, entry, @@ -1423,7 +1483,8 @@ impl Vm { compile_profile, drop_contract_events_enabled, ); - self.jit_native_compile_time_ns = self + self.engine.jit_native_compile_time_ns = self + .engine .jit_native_compile_time_ns .saturating_add(elapsed_ns(compile_started)); let compiled = compile_result?; @@ -1463,10 +1524,10 @@ impl Vm { let mut code = compiled.code; code.extend_from_slice(&dispatcher.code); let code = Arc::<[u8]>::from(code.into_boxed_slice()); - if self.native_traces.len() <= trace_id { - self.native_traces.resize_with(trace_id + 1, || None); + if self.engine.native_traces.len() <= trace_id { + self.engine.native_traces.resize_with(trace_id + 1, || None); } - self.native_traces[trace_id] = Some(NativeTrace { + self.engine.native_traces[trace_id] = Some(NativeTrace { _keepalive: keepalive, _direct_keepalives: direct_keepalives, entry, @@ -1487,21 +1548,24 @@ impl Vm { } pub fn jit_native_trace_count(&self) -> usize { - self.native_traces.iter().flatten().count() + self.engine.native_traces.iter().flatten().count() } pub fn jit_native_exec_count(&self) -> u64 { - self.native_trace_exec_count + self.engine.native_trace_exec_count } pub(crate) fn jit_native_inherited_target(&self) -> usize { - if !self.jit_native_direct_links_enabled || self.active_frame_has_shared_capture_cells() { + if !self.engine.jit_native_direct_links_enabled + || self.active_frame_has_shared_capture_cells() + { return 0; } let Some(trace_id) = self.compiled_trace_for_active_entry() else { return 0; }; - self.native_traces + self.engine + .native_traces .get(trace_id) .and_then(Option::as_ref) .map(|native| native.tail_entry as usize) @@ -1510,24 +1574,25 @@ impl Vm { pub fn set_jit_native_direct_links_enabled(&mut self, enabled: bool) { let cross_frame_enabled = enabled; - if self.jit_native_direct_links_enabled == enabled - && self.jit_native_direct_cross_frame_enabled == cross_frame_enabled + if self.engine.jit_native_direct_links_enabled == enabled + && self.engine.jit_native_direct_cross_frame_enabled == cross_frame_enabled { return; } self.clear_native_direct_links(); self.disconnect_native_regions(); - self.native_traces.clear(); - self.jit_native_direct_links_enabled = enabled; - self.jit_native_direct_cross_frame_enabled = cross_frame_enabled; - self.jit_native_direct_link_count = 0; - self.jit_native_active_direct_trace_id = usize::MAX; - self.jit_native_direct_escape_streak = 0; - self.jit_native_direct_region_fallback = false; + self.engine.native_traces.clear(); + self.engine.jit_native_direct_links_enabled = enabled; + self.engine.jit_native_direct_cross_frame_enabled = cross_frame_enabled; + self.engine.jit_native_direct_link_count = 0; + self.engine.jit_native_active_direct_trace_id = usize::MAX; + self.engine.jit_native_direct_escape_streak = 0; + self.engine.jit_native_direct_region_fallback = false; } pub fn jit_native_region_count(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .filter(|native| native.region.is_some()) @@ -1535,19 +1600,20 @@ impl Vm { } pub fn jit_native_region_entry_count(&self) -> u64 { - self.jit_native_region_entry_count + self.engine.jit_native_region_entry_count } pub fn jit_native_internal_region_edge_count(&self) -> u64 { - self.jit_native_region_edge_count + self.engine.jit_native_region_edge_count } pub fn jit_native_direct_link_count(&self) -> u64 { - self.jit_native_direct_link_count + self.engine.jit_native_direct_link_count } pub fn jit_native_active_direct_link_slot_count(&self) -> usize { - self.native_traces + self.engine + .native_traces .iter() .flatten() .flat_map(|native| native.direct_slots.values()) @@ -1556,19 +1622,21 @@ impl Vm { } pub fn jit_helper_fallback_count(&self) -> u64 { - self.jit_helper_fallback_count + self.engine.jit_helper_fallback_count } pub fn jit_native_link_handoff_count(&self) -> u64 { - self.jit_native_link_handoff_count + self.engine.jit_native_link_handoff_count } fn record_jit_helper_fallback(&mut self) { - self.jit_helper_fallback_count = self.jit_helper_fallback_count.saturating_add(1); + self.engine.jit_helper_fallback_count = + self.engine.jit_helper_fallback_count.saturating_add(1); } fn record_jit_link_handoff(&mut self) { - self.jit_native_link_handoff_count = self.jit_native_link_handoff_count.saturating_add(1); + self.engine.jit_native_link_handoff_count = + self.engine.jit_native_link_handoff_count.saturating_add(1); } } diff --git a/src/vm/jit/trace.rs b/src/vm/jit/trace.rs index d3a92072..3e4fa193 100644 --- a/src/vm/jit/trace.rs +++ b/src/vm/jit/trace.rs @@ -147,6 +147,7 @@ pub enum JitTraceTerminal { Halt, BranchExit, CallValue, + CallScript, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1251,59 +1252,31 @@ fn scan_loop_headers(program: &Program) -> Vec { let mut ip = 0usize; while ip < code.len() { - let opcode = code[ip]; + let Some(opcode) = OpCode::try_from(code[ip]).ok() else { + // Unknown opcode: its length cannot be determined, so advance + // a single byte rather than misaligning the scan. + ip = ip.saturating_add(1); + continue; + }; let instr_ip = ip; - ip = ip.saturating_add(1); - match opcode { - x if x == OpCode::Ldc as u8 => { - if read_u32(code, &mut ip).is_none() { - break; - } - } - x if x == OpCode::Br as u8 || x == OpCode::Brfalse as u8 => { - let Some(target_u32) = read_u32(code, &mut ip) else { - break; - }; - let target = target_u32 as usize; - if target <= instr_ip && target < headers.len() { - headers[target] = true; - } - } - x if x == OpCode::Ldloc as u8 || x == OpCode::Stloc as u8 => { - if read_u8(code, &mut ip).is_none() { - break; - } - } - x if x == OpCode::Call as u8 => { - if read_u16(code, &mut ip).is_none() { - break; - } - if read_u8(code, &mut ip).is_none() { - break; - } + if opcode == OpCode::Br || opcode == OpCode::Brfalse { + ip = ip.saturating_add(1); + let Some(target_u32) = read_u32(code, &mut ip) else { + break; + }; + let target = target_u32 as usize; + if target <= instr_ip && target < headers.len() { + headers[target] = true; } - _ => {} } + // Advance by the full instruction length (opcode plus operands) so + // operand bytes are never interpreted as opcodes. + ip = instr_ip.saturating_add(1 + opcode.operand_len()); } headers } -fn read_u8(code: &[u8], ip: &mut usize) -> Option { - let value = *code.get(*ip)?; - *ip = ip.saturating_add(1); - Some(value) -} - -fn read_u16(code: &[u8], ip: &mut usize) -> Option { - if ip.saturating_add(2) > code.len() { - return None; - } - let bytes = [code[*ip], code[*ip + 1]]; - *ip = ip.saturating_add(2); - Some(u16::from_le_bytes(bytes)) -} - fn read_u32(code: &[u8], ip: &mut usize) -> Option { if ip.saturating_add(4) > code.len() { return None; @@ -2023,6 +1996,32 @@ mod tests { assert!(!headers[branch_ip as usize]); } + #[test] + fn scan_loop_headers_skips_call_script_operand_bytes() { + // CallScript(12, 0) encodes as 0x1A followed by five operand bytes. + // The first operand byte is 0x0C (Brfalse) and the remaining bytes + // decode as a backward branch target of 0: a walker that does not + // advance over the full operand span would mark offset 0 as a false + // loop header. + let mut code = vec![OpCode::CallScript as u8]; + code.extend_from_slice(&12u32.to_le_bytes()); + code.push(0); + let loop_ip = code.len() as u32; + code.push(OpCode::Nop as u8); + let branch_ip = code.len() as u32; + code.push(OpCode::Br as u8); + code.extend_from_slice(&loop_ip.to_le_bytes()); + let program = Program::new(vec![], code); + + let headers = scan_loop_headers(&program); + assert!( + !headers[0], + "CallScript operand bytes must not be interpreted as a branch" + ); + assert!(headers[loop_ip as usize]); + assert!(!headers[branch_ip as usize]); + } + #[test] fn callable_side_exit_backoff_resets_on_native_progress() { if !native_jit_supported() { diff --git a/src/vm/mod.rs b/src/vm/mod.rs index ba148aec..4acd749c 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -1,29 +1,49 @@ -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, Weak}; +use std::sync::{Arc, Mutex}; pub(crate) mod aot; +mod async_host; +mod capability; pub mod diagnostics; +mod engine; mod epoch; mod fuel; mod host; +mod host_runtime; +mod instance; +pub mod invocation; pub(crate) mod jit; mod map_iter; pub(crate) mod native; +pub mod program; +mod run_context; mod store; mod superinstructions; #[cfg(test)] mod tests; pub use self::aot::AotArtifactError; + +pub use self::async_host::{ + CaptureAsyncHostContext, HostAsyncBridge, HostFuture, HostFutureOutput, +}; +pub use self::capability::{CapabilityProfile, CapabilityProfileBuilder}; +use self::engine::Engine; pub use self::epoch::{EpochCheckpoint, EpochHandle}; pub use self::fuel::FuelCheckpoint; pub use self::host::{ - CallOutcome, CallReturn, HostArgsFunction, HostAsyncBridge, HostBindingPlan, HostFunction, - HostFunctionRegistry, HostOpId, HostStackFunction, StaticHostArgsFunction, StaticHostFunction, + CallOutcome, CallReturn, HostArgsFunction, HostBindingPlan, HostFunction, HostFunctionRegistry, + HostOpId, HostStackFunction, StaticHostArgsFunction, StaticHostFunction, StaticHostStackFunction, }; -use self::host::{HostCallExecOutcome, VmHostFunction, WaitingHostOp}; +use self::host::{HostCallExecOutcome, VmHostFunction}; +use self::host_runtime::HostRuntime; +use self::instance::{ExecutionFrame, FrameContinuation, Instance, QueuedCallable}; +pub use self::invocation::{Invocation, InvocationError, InvocationItem, InvocationPoll}; +use self::run_context::{InterruptMode, RunContext}; +pub use crate::builtins::runtime::cancellation::CancellationReason; + pub use crate::bytecode::{ CallableTarget, CallableValue, HostImport, OpCode, Program, Value, ValueType, }; @@ -89,6 +109,9 @@ pub enum VmError { expected: u8, got: u8, }, + /// `CallScript` targeted a prototype whose capture layout requires a + /// callable environment, which a static script call cannot supply. + CallScriptRequiresEnvironment(u32), CallStackOverflow { limit: usize, }, @@ -160,6 +183,10 @@ impl std::fmt::Display for VmError { f, "invalid call arity for callable {prototype_id}: expected {expected}, got {got}" ), + VmError::CallScriptRequiresEnvironment(prototype_id) => write!( + f, + "callscript prototype {prototype_id} requires a callable environment" + ), VmError::CallStackOverflow { limit } => { write!(f, "script call stack limit {limit} exceeded") } @@ -228,25 +255,6 @@ pub struct InterpreterMetrics { pub local_type_hint_hit_count: u64, } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[repr(u8)] -enum InterruptMode { - None = 0, - Fuel = 1, - Epoch = 2, -} - -impl InterruptMode { - fn label(self) -> &'static str { - match self { - Self::None => "none", - Self::Fuel => "fuel", - Self::Epoch => "epoch", - } - } -} -type RuntimePrintSink = dyn FnMut(String) + Send; - type PackedOperandTypes = u8; const NO_OPERAND_TYPE_HINT: PackedOperandTypes = 0; @@ -283,129 +291,12 @@ pub struct VmExecutionFrameSnapshot { pub prototype_id: Option, } -#[allow(dead_code)] -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum FrameContinuation { - Halt, - ResumeBytecode { return_ip: usize }, - ReturnToHost, -} - -#[allow(dead_code)] -#[derive(Clone, Debug)] -pub(crate) struct ExecutionFrame { - pub(crate) continuation: FrameContinuation, - pub(crate) operand_stack_base: usize, - pub(crate) local_base: usize, - pub(crate) local_count: usize, - pub(crate) prototype_id: Option, -} - -impl ExecutionFrame { - fn root(local_count: usize) -> Self { - Self { - continuation: FrameContinuation::Halt, - operand_stack_base: 0, - local_base: 0, - local_count, - prototype_id: None, - } - } -} - -#[derive(Clone, Debug)] -struct QueuedCallable { - callable: Value, - args: Vec, - subscription: Option>, -} - pub struct Vm { program: Arc, - #[allow(dead_code)] - program_constants_ptr: usize, - #[allow(dead_code)] - program_constants_len: usize, - #[allow(dead_code)] - native_helper_fn: usize, - #[allow(dead_code)] - native_interrupt_helper_fn: usize, - program_cache_key: u64, - program_cache_key_ready: bool, - ip: usize, - stack: Vec, - locals: Vec, - capture_cells: HashMap, - shared_capture_slots: HashSet, - operand_type_hints: Option>, - decoded_instruction_data: Arc, - host_functions: Vec, - host_function_symbols: HashMap, - builtin_overrides: HashMap, - resolved_calls: Vec, - resolved_calls_dirty: bool, - call_depth: usize, - max_script_call_depth: usize, - execution_frames: Vec, - active_local_base_cache: usize, - active_operand_stack_base_cache: usize, - host_return: Option, - queued_callables: VecDeque, - completed_callable_results: VecDeque, - owned_callables: Vec>, - callback_registry_flags: Vec>, - draining_queued_callables: bool, - shutdown: bool, - aot_program: Option, - aot_exec_count: u64, - aot_interpreter_boundary_hit: bool, - jit: jit::TraceJitEngine, - native_traces: Vec>, - native_trace_exec_count: u64, - jit_native_region_entry_count: u64, - jit_native_region_edge_count: u64, - jit_native_direct_link_count: u64, - jit_native_direct_links_enabled: bool, - jit_native_direct_cross_frame_enabled: bool, - jit_native_active_direct_trace_id: usize, - jit_native_direct_escape_streak: u16, - jit_native_direct_region_fallback: bool, - jit_native_compile_time_ns: u64, - jit_native_region_compile_time_ns: u64, - jit_trace_exit_count: u64, - jit_native_loop_back_count: u64, - jit_native_link_handoff_count: u64, - jit_native_link_dispatch_depth: u32, - jit_helper_fallback_count: u64, - jit_native_bridge_stats_enabled: bool, - jit_native_bridge_counts: HashMap<&'static str, u64>, - async_bridge: Option>, - runtime_print_sink: Option>, - waiting_host_op: Option, - next_host_op_id: HostOpId, - pub(crate) io_state: crate::builtins::runtime::IoState, - regex_cache: crate::builtins::runtime::regex::RegexCache, - map_iterators: Vec>>, - epoch_handle: EpochHandle, - #[allow(dead_code)] - epoch_counter_ptr: usize, - interrupt_mode: InterruptMode, - fuel_remaining: u64, - fuel_check_interval: u32, - fuel_ops_until_check: u32, - epoch_deadline: u64, - epoch_deadline_delta: u64, - epoch_rearm_pending: bool, - last_yield_reason: Option, - drop_contract_events_enabled: bool, - drop_contract_events: u64, - operand_hint_hit_count: u64, - operand_hint_miss_count: u64, - typed_builtin_fast_path_count: u64, - projection_fast_path_count: u64, - generic_builtin_call_count: u64, - scalar_superinstruction_count: u64, - local_type_hint_hit_count: u64, + pub(crate) engine: Engine, + pub(crate) instance: Instance, + pub(crate) run_ctx: RunContext, + pub(crate) host: HostRuntime, } pub(crate) enum ExecOutcome { @@ -660,126 +551,21 @@ impl Vm { } pub fn new_shared_with_jit_config(program: Arc, jit_config: jit::JitConfig) -> Self { - let program_constants_ptr = program.constants.as_ptr(); - let program_constants_len = program.constants.len(); - let local_count = program.local_count; - let operand_type_hints = program.shared_operand_type_hints(); - let decoded_instruction_data = program.shared_decoded_instruction_data(); - let epoch_handle = EpochHandle::default(); - let epoch_counter_ptr = epoch_handle.as_ptr() as usize; - let mut vm = Self { + let engine = Engine::new(jit_config, &program); + let mut instance = Instance::new(&program); + instance.initialize_root_callable_bindings(&program); + Self { program, - program_constants_ptr: program_constants_ptr as usize, - program_constants_len, - native_helper_fn: native::helper_entry_address(), - native_interrupt_helper_fn: native::interrupt_helper_entry_address(), - program_cache_key: 0, - program_cache_key_ready: false, - ip: 0, - stack: Vec::new(), - locals: vec![Value::Null; local_count], - capture_cells: HashMap::new(), - shared_capture_slots: HashSet::new(), - operand_type_hints, - decoded_instruction_data, - host_functions: Vec::new(), - host_function_symbols: HashMap::new(), - builtin_overrides: HashMap::new(), - resolved_calls: Vec::new(), - resolved_calls_dirty: true, - call_depth: 0, - max_script_call_depth: DEFAULT_MAX_SCRIPT_CALL_DEPTH, - execution_frames: vec![ExecutionFrame::root(local_count)], - active_local_base_cache: 0, - active_operand_stack_base_cache: 0, - host_return: None, - queued_callables: VecDeque::new(), - completed_callable_results: VecDeque::new(), - owned_callables: Vec::new(), - callback_registry_flags: Vec::new(), - draining_queued_callables: false, - shutdown: false, - aot_program: None, - aot_exec_count: 0, - aot_interpreter_boundary_hit: false, - jit: jit::TraceJitEngine::new(jit_config), - native_traces: Vec::new(), - native_trace_exec_count: 0, - jit_native_region_entry_count: 0, - jit_native_region_edge_count: 0, - jit_native_direct_link_count: 0, - jit_native_direct_links_enabled: true, - jit_native_direct_cross_frame_enabled: false, - jit_native_active_direct_trace_id: usize::MAX, - jit_native_direct_escape_streak: 0, - jit_native_direct_region_fallback: false, - jit_native_compile_time_ns: 0, - jit_native_region_compile_time_ns: 0, - jit_trace_exit_count: 0, - jit_native_loop_back_count: 0, - jit_native_link_handoff_count: 0, - jit_native_link_dispatch_depth: 0, - jit_helper_fallback_count: 0, - jit_native_bridge_stats_enabled: false, - jit_native_bridge_counts: HashMap::new(), - async_bridge: None, - runtime_print_sink: None, - waiting_host_op: None, - next_host_op_id: 1, - io_state: crate::builtins::runtime::IoState::default(), - regex_cache: crate::builtins::runtime::regex::RegexCache::default(), - map_iterators: Vec::new(), - epoch_handle, - epoch_counter_ptr, - interrupt_mode: InterruptMode::None, - fuel_remaining: 0, - fuel_check_interval: 1, - fuel_ops_until_check: 1, - epoch_deadline: 0, - epoch_deadline_delta: 0, - epoch_rearm_pending: false, - last_yield_reason: None, - drop_contract_events_enabled: false, - drop_contract_events: 0, - operand_hint_hit_count: 0, - operand_hint_miss_count: 0, - typed_builtin_fast_path_count: 0, - projection_fast_path_count: 0, - generic_builtin_call_count: 0, - scalar_superinstruction_count: 0, - local_type_hint_hit_count: 0, - }; - vm.initialize_root_callable_bindings(); - vm - } - - fn initialize_root_callable_bindings(&mut self) { - let bindings = self.program.root_callable_bindings.clone(); - for binding in bindings { - let Some(kind) = self - .program - .callable_prototypes - .get(binding.prototype_id as usize) - .map(|prototype| prototype.kind) - else { - continue; - }; - if binding.local_slot as usize >= self.locals.len() { - continue; - } - let callable = Arc::new(CallableValue { - prototype_id: binding.prototype_id, - kind, - env: None, - }); - self.owned_callables.push(Arc::downgrade(&callable)); - self.locals[binding.local_slot as usize] = Value::Callable(callable); + engine, + instance, + run_ctx: RunContext::default(), + host: HostRuntime::default(), } } /// Returns the maximum number of simultaneously active script call frames. pub fn max_script_call_depth(&self) -> usize { - self.max_script_call_depth + self.instance.max_script_call_depth } /// Sets the maximum number of simultaneously active script call frames. @@ -790,38 +576,34 @@ impl Vm { if limit == 0 { return Err(VmError::InvalidCallStackLimit(limit)); } - self.max_script_call_depth = limit; + self.instance.max_script_call_depth = limit; Ok(()) } fn ensure_program_cache_key(&mut self) -> u64 { - if !self.program_cache_key_ready { - self.program_cache_key = compute_program_cache_key(&self.program); - self.program_cache_key_ready = true; - } - self.program_cache_key + self.engine.ensure_program_cache_key(&self.program) } #[inline(always)] fn fuel_metering_enabled(&self) -> bool { - self.interrupt_mode == InterruptMode::Fuel + self.run_ctx.interrupt_mode == InterruptMode::Fuel } #[inline(always)] fn epoch_interruption_enabled(&self) -> bool { - self.interrupt_mode == InterruptMode::Epoch + self.run_ctx.interrupt_mode == InterruptMode::Epoch } #[inline(always)] fn interruption_enabled(&self) -> bool { - self.interrupt_mode != InterruptMode::None + self.run_ctx.interrupt_mode != InterruptMode::None } /// Returns the maximum number of compiled regular expressions retained by this VM. /// /// New VMs default to 512 entries. A capacity of zero disables caching. pub fn regex_cache_capacity(&self) -> usize { - self.regex_cache.capacity() + self.engine.regex_cache.capacity() } /// Changes this VM's compiled regular-expression cache capacity. @@ -829,67 +611,68 @@ impl Vm { /// Shrinking evicts least-recently-used entries immediately. Setting zero clears /// all entries and disables caching until a positive capacity is configured. pub fn set_regex_cache_capacity(&mut self, capacity: usize) { - self.regex_cache.set_capacity(capacity); + self.engine.regex_cache.set_capacity(capacity); } pub fn regex_cache_entry_count(&self) -> usize { - self.regex_cache.len() + self.engine.regex_cache.len() } pub fn regex_cache_compile_count(&self) -> u64 { - self.regex_cache.compile_count() + self.engine.regex_cache.compile_count() } pub fn regex_cache_hit_count(&self) -> u64 { - self.regex_cache.hit_count() + self.engine.regex_cache.hit_count() } pub(crate) fn cached_regex( &mut self, pattern: &str, ) -> Result, regex::Error> { - self.regex_cache.get_or_compile(pattern) + self.engine.regex_cache.get_or_compile(pattern) } pub fn set_jit_native_bridge_stats_enabled(&mut self, enabled: bool) { - self.jit_native_bridge_stats_enabled = enabled; + self.engine.jit_native_bridge_stats_enabled = enabled; if !enabled { - self.jit_native_bridge_counts.clear(); + self.engine.jit_native_bridge_counts.clear(); } } pub fn jit_native_bridge_stats_enabled(&self) -> bool { - self.jit_native_bridge_stats_enabled + self.engine.jit_native_bridge_stats_enabled } pub fn clear_jit_native_bridge_stats(&mut self) { - self.jit_native_bridge_counts.clear(); + self.engine.jit_native_bridge_counts.clear(); } pub fn interpreter_metrics_snapshot(&self) -> InterpreterMetrics { InterpreterMetrics { - operand_hint_hit_count: self.operand_hint_hit_count, - operand_hint_miss_count: self.operand_hint_miss_count, - typed_builtin_fast_path_count: self.typed_builtin_fast_path_count, - projection_fast_path_count: self.projection_fast_path_count, - generic_builtin_call_count: self.generic_builtin_call_count, - scalar_superinstruction_count: self.scalar_superinstruction_count, - local_type_hint_hit_count: self.local_type_hint_hit_count, + operand_hint_hit_count: self.instance.operand_hint_hit_count, + operand_hint_miss_count: self.instance.operand_hint_miss_count, + typed_builtin_fast_path_count: self.instance.typed_builtin_fast_path_count, + projection_fast_path_count: self.instance.projection_fast_path_count, + generic_builtin_call_count: self.instance.generic_builtin_call_count, + scalar_superinstruction_count: self.instance.scalar_superinstruction_count, + local_type_hint_hit_count: self.instance.local_type_hint_hit_count, } } pub fn clear_interpreter_metrics(&mut self) { - self.operand_hint_hit_count = 0; - self.operand_hint_miss_count = 0; - self.typed_builtin_fast_path_count = 0; - self.projection_fast_path_count = 0; - self.generic_builtin_call_count = 0; - self.scalar_superinstruction_count = 0; - self.local_type_hint_hit_count = 0; + self.instance.operand_hint_hit_count = 0; + self.instance.operand_hint_miss_count = 0; + self.instance.typed_builtin_fast_path_count = 0; + self.instance.projection_fast_path_count = 0; + self.instance.generic_builtin_call_count = 0; + self.instance.scalar_superinstruction_count = 0; + self.instance.local_type_hint_hit_count = 0; } pub fn jit_native_bridge_stats_snapshot(&self) -> Vec<(&'static str, u64)> { let mut entries: Vec<(&'static str, u64)> = self + .engine .jit_native_bridge_counts .iter() .map(|(name, count)| (*name, *count)) @@ -900,10 +683,11 @@ impl Vm { #[allow(dead_code)] pub(in crate::vm) fn record_native_bridge_hit(&mut self, bridge_name: &'static str) { - if !self.jit_native_bridge_stats_enabled { + if !self.engine.jit_native_bridge_stats_enabled { return; } let entry = self + .engine .jit_native_bridge_counts .entry(bridge_name) .or_insert(0); @@ -916,44 +700,13 @@ impl Vm { /// Locals are reset to `Null`, stack is cleared, and instruction pointer is /// rewound to the program entry. pub fn reset_for_reuse(&mut self) { - self.invalidate_callback_registries(); - self.cancel_waiting_host_op(); - self.ip = 0; - self.drop_contract_events = 0; - self.last_yield_reason = None; - self.epoch_rearm_pending = false; - self.clear_fuel(); - self.clear_epoch_deadline(); - self.clear_stack_with_drop_contract(); - self.capture_cells.clear(); - self.shared_capture_slots.clear(); - self.clear_locals_with_drop_contract(); - self.owned_callables.clear(); - self.locals.resize(self.program.local_count, Value::Null); - self.initialize_root_callable_bindings(); - crate::builtins::runtime::close_all_handles(self); - self.call_depth = 0; - self.execution_frames.clear(); - self.execution_frames - .push(ExecutionFrame::root(self.program.local_count)); - self.active_local_base_cache = 0; - self.active_operand_stack_base_cache = 0; - self.host_return = None; - self.queued_callables.clear(); - self.completed_callable_results.clear(); - self.owned_callables.clear(); - self.draining_queued_callables = false; - self.shutdown = false; - self.aot_interpreter_boundary_hit = self - .aot_program - .as_ref() - .is_some_and(|program| program.interpreter_boundary_only); - self.waiting_host_op = None; - self.io_state = crate::builtins::runtime::IoState::default(); - self.map_iterators.clear(); - self.jit.reset_runtime_backoff(); - self.jit.clear_call_site_profiles(); - self.clear_interpreter_metrics(); + self.cancel_waiting_host_op_with_reason( + crate::builtins::runtime::cancellation::CancellationReason::VmReset, + ); + self.host.reset_for_reuse(); + self.run_ctx.reset_for_reuse(); + self.instance.reset(&self.program); + self.engine.reset_runtime_state(&self.program); } fn validate_map_iterator_slot(&self, slot: usize) -> VmResult<()> { @@ -972,11 +725,11 @@ impl Vm { map: crate::bytecode::SharedMap, ) -> VmResult<()> { self.validate_map_iterator_slot(slot)?; - let depth = self.call_depth; - if self.map_iterators.len() <= depth { - self.map_iterators.resize_with(depth + 1, Vec::new); + let depth = self.instance.call_depth; + if self.instance.map_iterators.len() <= depth { + self.instance.map_iterators.resize_with(depth + 1, Vec::new); } - let frame = &mut self.map_iterators[depth]; + let frame = &mut self.instance.map_iterators[depth]; if frame.len() <= slot { frame.resize_with(slot + 1, || None); } @@ -986,9 +739,13 @@ impl Vm { pub(crate) fn advance_map_iterator(&mut self, slot: usize) -> VmResult { self.validate_map_iterator_slot(slot)?; - let frame = self.map_iterators.get_mut(self.call_depth).ok_or_else(|| { - VmError::HostError("map iterator frame is not initialized".to_string()) - })?; + let frame = self + .instance + .map_iterators + .get_mut(self.instance.call_depth) + .ok_or_else(|| { + VmError::HostError("map iterator frame is not initialized".to_string()) + })?; let state = frame .get_mut(slot) .and_then(Option::as_mut) @@ -1002,8 +759,9 @@ impl Vm { pub(crate) fn take_map_iterator_key(&mut self, slot: usize) -> VmResult { self.validate_map_iterator_slot(slot)?; - self.map_iterators - .get_mut(self.call_depth) + self.instance + .map_iterators + .get_mut(self.instance.call_depth) .and_then(|frame| frame.get_mut(slot)) .and_then(Option::as_mut) .and_then(map_iter::MapIteratorState::take_key) @@ -1012,8 +770,9 @@ impl Vm { pub(crate) fn take_map_iterator_value(&mut self, slot: usize) -> VmResult { self.validate_map_iterator_slot(slot)?; - self.map_iterators - .get_mut(self.call_depth) + self.instance + .map_iterators + .get_mut(self.instance.call_depth) .and_then(|frame| frame.get_mut(slot)) .and_then(Option::as_mut) .and_then(map_iter::MapIteratorState::take_value) @@ -1023,8 +782,9 @@ impl Vm { pub(crate) fn close_map_iterator(&mut self, slot: usize) -> VmResult<()> { self.validate_map_iterator_slot(slot)?; if let Some(state) = self + .instance .map_iterators - .get_mut(self.call_depth) + .get_mut(self.instance.call_depth) .and_then(|frame| frame.get_mut(slot)) { *state = None; @@ -1033,7 +793,7 @@ impl Vm { } fn close_all_map_iterators(&mut self) { - for frame in &mut self.map_iterators { + for frame in &mut self.instance.map_iterators { for state in frame { state.take(); } @@ -1042,19 +802,21 @@ impl Vm { #[inline(always)] pub(super) fn active_operand_stack_base(&self) -> usize { - self.active_operand_stack_base_cache + self.instance.active_operand_stack_base_cache } #[inline(always)] pub(super) fn active_operand_stack_len(&self) -> usize { - self.stack + self.instance + .stack .len() .saturating_sub(self.active_operand_stack_base()) } #[inline(always)] pub(super) fn active_frame_key(&self) -> u64 { - self.execution_frames + self.instance + .execution_frames .last() .and_then(|frame| frame.prototype_id) .map(u64::from) @@ -1063,11 +825,11 @@ impl Vm { #[inline(always)] pub(super) fn active_local_base(&self) -> usize { - self.active_local_base_cache + self.instance.active_local_base_cache } pub(super) fn active_local_types(&self) -> Vec { - self.locals[self.active_local_base()..] + self.instance.locals[self.active_local_base()..] .iter() .map(|value| match value { Value::Null => ValueType::Null, @@ -1085,9 +847,10 @@ impl Vm { pub(super) fn active_local_callable_prototypes(&self) -> Option>> { let base = self.active_local_base(); - let mut prototypes = Vec::with_capacity(self.locals.len().saturating_sub(base)); - for (offset, value) in self.locals[base..].iter().enumerate() { - let prototype_id = if let Some(cell) = self.capture_cells.get(&(base + offset)) { + let mut prototypes = Vec::with_capacity(self.instance.locals.len().saturating_sub(base)); + for (offset, value) in self.instance.locals[base..].iter().enumerate() { + let prototype_id = if let Some(cell) = self.instance.capture_cells.get(&(base + offset)) + { let value = cell.lock().ok()?; inline_compatible_callable_prototype(&value) } else { @@ -1099,21 +862,23 @@ impl Vm { } pub(super) fn active_frame_has_shared_capture_cells(&self) -> bool { - if self.shared_capture_slots.is_empty() { + if self.instance.shared_capture_slots.is_empty() { return false; } - let Some(frame) = self.execution_frames.last() else { + let Some(frame) = self.instance.execution_frames.last() else { return false; }; let base = frame.local_base; let end = base.saturating_add(frame.local_count); - self.shared_capture_slots + self.instance + .shared_capture_slots .iter() .any(|absolute| base <= *absolute && *absolute < end) } fn script_frame_depth(&self) -> usize { - self.execution_frames + self.instance + .execution_frames .iter() .filter(|frame| frame.prototype_id.is_some()) .count() @@ -1125,7 +890,8 @@ impl Vm { .active_local_base() .checked_add(index as usize) .ok_or(VmError::InvalidLocal(index))?; - self.locals + self.instance + .locals .get(absolute) .map(|_| absolute) .ok_or(VmError::InvalidLocal(index)) @@ -1134,8 +900,8 @@ impl Vm { #[inline(always)] fn load_local_value(&self, index: u8) -> VmResult { let absolute = self.absolute_local_index(index)?; - if self.capture_cells.is_empty() { - return Ok(self.locals[absolute].clone()); + if self.instance.capture_cells.is_empty() { + return Ok(self.instance.locals[absolute].clone()); } self.load_local_value_with_captures(absolute, index) } @@ -1143,13 +909,14 @@ impl Vm { #[cold] #[inline(never)] fn load_local_value_with_captures(&self, absolute: usize, index: u8) -> VmResult { - if let Some(cell) = self.capture_cells.get(&absolute) { + if let Some(cell) = self.instance.capture_cells.get(&absolute) { return cell .lock() .map(|value| value.clone()) .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned")); } - self.locals + self.instance + .locals .get(absolute) .cloned() .ok_or(VmError::InvalidLocal(index)) @@ -1158,8 +925,8 @@ impl Vm { #[inline(always)] pub(super) fn local_numeric_value(&self, index: u8) -> Option { let absolute = self.absolute_local_index(index).ok()?; - if self.capture_cells.is_empty() { - return match self.locals.get(absolute)? { + if self.instance.capture_cells.is_empty() { + return match self.instance.locals.get(absolute)? { Value::Int(value) => Some(NumericValue::Int(*value)), Value::Float(value) => Some(NumericValue::Float(*value)), _ => None, @@ -1172,10 +939,14 @@ impl Vm { #[inline(never)] fn local_numeric_value_with_captures(&self, absolute: usize) -> Option { let captured = self + .instance .capture_cells .get(&absolute) .and_then(|cell| cell.lock().ok().map(|value| value.clone())); - match captured.as_ref().or_else(|| self.locals.get(absolute))? { + match captured + .as_ref() + .or_else(|| self.instance.locals.get(absolute))? + { Value::Int(value) => Some(NumericValue::Int(*value)), Value::Float(value) => Some(NumericValue::Float(*value)), _ => None, @@ -1183,33 +954,33 @@ impl Vm { } pub fn drop_contract_event_count(&self) -> u64 { - self.drop_contract_events + self.instance.drop_contract_events } pub fn set_drop_contract_events_enabled(&mut self, enabled: bool) { - if self.drop_contract_events_enabled != enabled { + if self.instance.drop_contract_events_enabled != enabled { self.disconnect_native_regions(); - self.native_traces.clear(); + self.engine.invalidate_codegen_caches(); } - self.drop_contract_events_enabled = enabled; + self.instance.drop_contract_events_enabled = enabled; if !enabled { - self.drop_contract_events = 0; + self.instance.drop_contract_events = 0; } } pub fn drop_contract_events_enabled(&self) -> bool { - self.drop_contract_events_enabled + self.instance.drop_contract_events_enabled } fn interruption_mode_conflict(&self, requested: InterruptMode) -> VmError { VmError::InterruptionModeConflict { - active: self.interrupt_mode.label(), + active: self.run_ctx.interrupt_mode.label(), requested: requested.label(), } } fn reset_interrupt_countdown(&mut self) { - self.fuel_ops_until_check = self.fuel_check_interval.max(1); + self.run_ctx.fuel_ops_until_check = self.run_ctx.fuel_check_interval.max(1); } pub fn run(&mut self) -> VmResult { @@ -1226,18 +997,17 @@ impl Vm { impl Drop for Vm { fn drop(&mut self) { - self.cancel_waiting_host_op(); - self.clear_stack_with_drop_contract(); - self.capture_cells.clear(); - self.shared_capture_slots.clear(); - self.clear_locals_with_drop_contract(); - crate::builtins::runtime::close_all_handles(self); + self.cancel_waiting_host_op_with_reason( + crate::builtins::runtime::cancellation::CancellationReason::VmReset, + ); + self.host.reset_for_reuse(); + self.instance.drop_cleanup(); } } impl Vm { pub(super) fn pop_value(&mut self) -> VmResult { - self.stack.pop().ok_or(VmError::StackUnderflow) + self.instance.stack.pop().ok_or(VmError::StackUnderflow) } pub(crate) fn bind_callable_value( @@ -1276,18 +1046,19 @@ impl Vm { let absolute = active_base .checked_add(usize::from(*source)) .ok_or(VmError::InvalidFrameState("capture source slot overflow"))?; - if absolute >= self.locals.len() { + if absolute >= self.instance.locals.len() { return Err(VmError::InvalidFrameState( "capture source exceeds active frame locals", )); } let cell = self + .instance .capture_cells .entry(absolute) .or_insert_with(|| Arc::new(Mutex::new(value))) .clone(); - self.shared_capture_slots.insert(absolute); - self.locals[absolute] = cell + self.instance.shared_capture_slots.insert(absolute); + self.instance.locals[absolute] = cell .lock() .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned"))? .clone(); @@ -1309,7 +1080,9 @@ impl Vm { kind: prototype.kind, env, }); - self.owned_callables.push(Arc::downgrade(&callable)); + self.instance + .owned_callables + .push(Arc::downgrade(&callable)); Ok(Value::Callable(callable)) } @@ -1319,26 +1092,97 @@ impl Vm { call_site_ip: Option, ) -> VmResult { let operand_count = argc as usize + 1; - if self.stack.len() < operand_count { + if self.instance.stack.len() < operand_count { return Err(VmError::StackUnderflow); } - let operand_stack_base = self.stack.len() - operand_count; - let mut operands = self.stack.split_off(operand_stack_base); + let operand_stack_base = self.instance.stack.len() - operand_count; + let mut operands = self.instance.stack.split_off(operand_stack_base); let callee = operands.remove(0); let Value::Callable(callable) = callee else { return Err(VmError::InvalidCallable); }; + let prototype_id = callable.prototype_id; + let continuation = FrameContinuation::ResumeBytecode { + return_ip: self.instance.ip, + }; + self.enter_script_frame( + prototype_id, + Some(callable), + operands, + operand_stack_base, + call_site_ip, + continuation, + ) + } + + /// Execute a static `CallScript(prototype_id, argc)` instruction. + /// + /// The operands are split off the stack and the frame is entered through + /// the shared [`Self::enter_script_frame`] helper with no callable value: + /// `CallScript` can never supply a callable environment, so capture- or + /// self-requiring prototypes are rejected there with a typed error. + fn execute_call_script( + &mut self, + prototype_id: u32, + argc: u8, + call_ip: usize, + ) -> VmResult { + let operand_count = argc as usize; + if self.instance.stack.len() < operand_count { + return Err(VmError::StackUnderflow); + } + let operand_stack_base = self.instance.stack.len() - operand_count; + let operands = self.instance.stack.split_off(operand_stack_base); + let continuation = FrameContinuation::ResumeBytecode { + return_ip: self.instance.ip, + }; + self.enter_script_frame( + prototype_id, + None, + operands, + operand_stack_base, + Some(call_ip), + continuation, + ) + } + + /// Shared script-frame entry for `CallValue` and `CallScript`. + /// + /// Enters a callable frame from `(prototype_id, optional callable value, + /// operands, continuation)`. `CallValue` passes the runtime callable + /// value, which carries the environment and provides the self binding; + /// `CallScript` passes `None` and must only reach environment-free + /// function prototypes. The helper preserves arity validation, schema + /// checks, depth limits, interruption ticks, the return continuation, + /// operand stack cleanup, root callable binding initialization, capture + /// cell wiring, and self-slot binding. + fn enter_script_frame( + &mut self, + prototype_id: u32, + callable: Option>, + operands: Vec, + operand_stack_base: usize, + call_site_ip: Option, + continuation: FrameContinuation, + ) -> VmResult { let prototype = self .program .callable_prototypes - .get(callable.prototype_id as usize) + .get(prototype_id as usize) .cloned() - .ok_or(VmError::InvalidCallablePrototype(callable.prototype_id))?; - if prototype.arity != argc { + .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; + // A call without a runtime callable value (`CallScript`) cannot + // populate capture cells or bind the function's self identity. + if callable.is_none() + && (!prototype.capture_slots.is_empty() || prototype.self_slot.is_some()) + { + return Err(VmError::CallScriptRequiresEnvironment(prototype_id)); + } + if prototype.arity != operands.len() as u8 { return Err(VmError::CallableArityMismatch { - prototype_id: callable.prototype_id, + prototype_id, expected: prototype.arity, - got: argc, + got: operands.len() as u8, }); } if let Some(crate::compiler::TypeSchema::Callable { params, .. }) = &prototype.schema @@ -1354,15 +1198,15 @@ impl Vm { match prototype.target { CallableTarget::ScriptFunction(function_id) => { if let Some(call_ip) = call_site_ip { - self.jit.observe_script_call_target( + self.engine.jit.observe_script_call_target( self.active_frame_key(), call_ip, - callable.prototype_id, + prototype_id, ); } - if self.call_depth >= self.max_script_call_depth { + if self.instance.call_depth >= self.instance.max_script_call_depth { return Err(VmError::CallStackOverflow { - limit: self.max_script_call_depth, + limit: self.instance.max_script_call_depth, }); } let function = self @@ -1370,19 +1214,20 @@ impl Vm { .script_functions .get(function_id as usize) .cloned() - .ok_or(VmError::InvalidCallablePrototype(callable.prototype_id))?; + .ok_or(VmError::InvalidCallablePrototype(prototype_id))?; if prototype.parameter_slots.len() != operands.len() { return Err(VmError::CallableArityMismatch { - prototype_id: callable.prototype_id, + prototype_id, expected: prototype.parameter_slots.len() as u8, - got: argc, + got: operands.len() as u8, }); } let inherited_callables = self + .instance .execution_frames .last() .map(|frame| { - self.locals[frame.local_base..frame.local_base + frame.local_count] + self.instance.locals[frame.local_base..frame.local_base + frame.local_count] .iter() .enumerate() .filter(|(_, value)| matches!(value, Value::Callable(_))) @@ -1390,9 +1235,10 @@ impl Vm { .collect::>() }) .unwrap_or_default(); - let local_base = self.locals.len(); + let local_base = self.instance.locals.len(); let local_count = prototype.frame_local_count; - self.locals + self.instance + .locals .resize(local_base.saturating_add(local_count), Value::Null); for binding in &self.program.root_callable_bindings { let relative = binding.local_slot as usize; @@ -1412,12 +1258,14 @@ impl Vm { kind, env: None, }); - self.owned_callables.push(Arc::downgrade(&callable)); - self.locals[local_base + relative] = Value::Callable(callable); + self.instance + .owned_callables + .push(Arc::downgrade(&callable)); + self.instance.locals[local_base + relative] = Value::Callable(callable); } for (slot, value) in inherited_callables { if slot < local_count { - self.locals[local_base + slot] = value; + self.instance.locals[local_base + slot] = value; } } for (slot, argument) in prototype.parameter_slots.iter().zip(operands) { @@ -1427,9 +1275,11 @@ impl Vm { "parameter slot is outside the script frame", )); } - self.locals[local_base + relative] = argument; + self.instance.locals[local_base + relative] = argument; } - if let Some(environment) = &callable.env { + if let Some(environment) = + callable.as_ref().and_then(|callable| callable.env.as_ref()) + { let cells = environment .cells .lock() @@ -1452,20 +1302,20 @@ impl Vm { )); } let absolute = local_base + relative; - self.locals[absolute] = cell + self.instance.locals[absolute] = cell .lock() .map_err(|_| { VmError::InvalidFrameState("capture cell lock is poisoned") })? .clone(); if prototype.self_slot != Some(*slot) { - self.capture_cells.insert(absolute, cell.clone()); + self.instance.capture_cells.insert(absolute, cell.clone()); if matches!( mode, crate::CaptureBindingMode::Borrow | crate::CaptureBindingMode::BorrowMut ) { - self.shared_capture_slots.insert(absolute); + self.instance.shared_capture_slots.insert(absolute); } } } @@ -1477,32 +1327,43 @@ impl Vm { "self slot is outside the script frame", )); } - self.locals[local_base + relative] = Value::Callable(callable.clone()); + let Some(callable) = callable else { + return Err(VmError::InvalidFrameState( + "self slot requires a callable value", + )); + }; + self.instance.locals[local_base + relative] = Value::Callable(callable.clone()); } - let return_ip = self.ip; - self.execution_frames.push(ExecutionFrame { - continuation: FrameContinuation::ResumeBytecode { return_ip }, + self.instance.execution_frames.push(ExecutionFrame { + continuation, operand_stack_base, local_base, local_count, - prototype_id: Some(callable.prototype_id), + prototype_id: Some(prototype_id), }); - self.active_local_base_cache = local_base; - self.active_operand_stack_base_cache = operand_stack_base; - self.call_depth = self.script_frame_depth(); - self.ip = function.entry_ip as usize; + self.instance.active_local_base_cache = local_base; + self.instance.active_operand_stack_base_cache = operand_stack_base; + self.instance.call_depth = self.script_frame_depth(); + self.instance.ip = function.entry_ip as usize; self.charge_interrupt_tick()?; Ok(ExecOutcome::Continue) } CallableTarget::HostImport(import_index) => { - self.stack.extend(operands); - let call_ip = self.ip.saturating_sub(2); + let Some(callable) = callable else { + // `CallScript` is a static script-function call and must + // never route a host-import prototype to the host path. + return Err(VmError::InvalidCallablePrototype(prototype_id)); + }; + let argc = operands.len() as u8; + self.instance.stack.extend(operands); + let call_ip = self.instance.ip.saturating_sub(2); match self.execute_host_call(import_index, argc, call_ip)? { HostCallExecOutcome::Returned => Ok(ExecOutcome::Continue), HostCallExecOutcome::Halted => Ok(ExecOutcome::Halted), HostCallExecOutcome::Yielded => { - self.stack - .insert(operand_stack_base, Value::Callable(callable)); + self.instance + .stack + .insert(operand_stack_base, Value::Callable(callable.clone())); Ok(ExecOutcome::Yielded) } HostCallExecOutcome::Pending(op_id) => Ok(ExecOutcome::Waiting(op_id)), @@ -1513,45 +1374,57 @@ impl Vm { fn complete_active_frame(&mut self) -> VmResult { let frame = self + .instance .execution_frames .pop() .ok_or(VmError::InvalidFrameState("missing active frame"))?; - self.active_local_base_cache = self + self.instance.active_local_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.local_base) .unwrap_or(0); - self.active_operand_stack_base_cache = self + self.instance.active_operand_stack_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.operand_stack_base) .unwrap_or(0); - if self.stack.len() < frame.operand_stack_base { + if self.instance.stack.len() < frame.operand_stack_base { return Err(VmError::InvalidFrameState( "operand stack is below the active frame base", )); } if matches!(frame.continuation, FrameContinuation::Halt) { - self.call_depth = self.script_frame_depth(); + self.instance.call_depth = self.script_frame_depth(); return Ok(ExecOutcome::Halted); } - let result = if self.stack.len() > frame.operand_stack_base { - self.stack.pop().expect("stack length checked above") + let result = if self.instance.stack.len() > frame.operand_stack_base { + self.instance + .stack + .pop() + .expect("stack length checked above") } else { Value::Null }; - while self.stack.len() > frame.operand_stack_base { - let value = self.stack.pop().expect("stack length checked above"); + while self.instance.stack.len() > frame.operand_stack_base { + let value = self + .instance + .stack + .pop() + .expect("stack length checked above"); self.drop_value_with_contract(value); } - self.call_depth = self.script_frame_depth(); + self.instance.call_depth = self.script_frame_depth(); if frame.prototype_id.is_some() { let frame_end = frame.local_base.saturating_add(frame.local_count); - self.capture_cells + self.instance + .capture_cells .retain(|absolute, _| *absolute < frame.local_base || *absolute >= frame_end); - self.shared_capture_slots + self.instance + .shared_capture_slots .retain(|absolute| *absolute < frame.local_base || *absolute >= frame_end); } @@ -1560,12 +1433,16 @@ impl Vm { .local_base .checked_add(frame.local_count) .ok_or(VmError::InvalidFrameState("local frame range overflow"))?; - if frame_end != self.locals.len() { + if frame_end != self.instance.locals.len() { return Err(VmError::InvalidFrameState( "active local frame does not end at the local stack tail", )); } - let drained = self.locals.drain(frame.local_base..).collect::>(); + let drained = self + .instance + .locals + .drain(frame.local_base..) + .collect::>(); for value in drained { self.drop_value_with_contract(value); } @@ -1585,16 +1462,16 @@ impl Vm { match frame.continuation { FrameContinuation::Halt => { - self.stack.push(result); + self.instance.stack.push(result); Ok(ExecOutcome::Halted) } FrameContinuation::ResumeBytecode { return_ip } => { - self.ip = return_ip; - self.stack.push(result); + self.instance.ip = return_ip; + self.instance.stack.push(result); Ok(ExecOutcome::Continue) } FrameContinuation::ReturnToHost => { - self.host_return = Some(result); + self.instance.host_return = Some(result); Ok(ExecOutcome::Halted) } } @@ -1602,25 +1479,25 @@ impl Vm { pub(super) fn can_fuse_call_ret_pattern(&self) -> bool { let code = &self.program.code; - self.ip < code.len() && code[self.ip] == OpCode::Ret as u8 + self.instance.ip < code.len() && code[self.instance.ip] == OpCode::Ret as u8 } pub(super) fn clear_stack_with_drop_contract(&mut self) { - let drained = self.stack.drain(..).collect::>(); + let drained = self.instance.stack.drain(..).collect::>(); for value in drained { self.drop_value_with_contract(value); } } pub(super) fn clear_locals_with_drop_contract(&mut self) { - for slot in 0..self.locals.len() { - let previous = std::mem::replace(&mut self.locals[slot], Value::Null); + for slot in 0..self.instance.locals.len() { + let previous = std::mem::replace(&mut self.instance.locals[slot], Value::Null); self.drop_value_with_contract(previous); } } pub(super) fn drop_value_with_contract(&mut self, value: Value) { - if self.drop_contract_events_enabled { + if self.instance.drop_contract_events_enabled { self.count_value_drop_contract(&value); } } @@ -1629,13 +1506,15 @@ impl Vm { match value { Value::Null => {} Value::Array(values) => { - self.drop_contract_events = self.drop_contract_events.saturating_add(1); + self.instance.drop_contract_events = + self.instance.drop_contract_events.saturating_add(1); for item in values.iter() { self.count_value_drop_contract(item); } } Value::Map(entries) => { - self.drop_contract_events = self.drop_contract_events.saturating_add(1); + self.instance.drop_contract_events = + self.instance.drop_contract_events.saturating_add(1); for (key, value) in entries.iter() { self.count_value_drop_contract(key); self.count_value_drop_contract(value); @@ -1647,14 +1526,15 @@ impl Vm { | Value::String(_) | Value::Bytes(_) | Value::Callable(_) => { - self.drop_contract_events = self.drop_contract_events.saturating_add(1); + self.instance.drop_contract_events = + self.instance.drop_contract_events.saturating_add(1); } } } #[inline(always)] pub(in crate::vm) fn charge_interrupt_tick(&mut self) -> VmResult<()> { - match self.interrupt_mode { + match self.run_ctx.interrupt_mode { InterruptMode::None => Ok(()), InterruptMode::Fuel => self.charge_fuel_tick(), InterruptMode::Epoch => self.charge_epoch_tick(), @@ -1664,15 +1544,15 @@ impl Vm { #[inline(always)] #[allow(dead_code)] pub(in crate::vm) fn charge_aot_call_boundary_interrupt(&mut self) -> VmResult<()> { - match self.interrupt_mode { + match self.run_ctx.interrupt_mode { InterruptMode::None => Ok(()), InterruptMode::Fuel => self.charge_fuel(1), InterruptMode::Epoch => { let current = self.current_epoch(); - if current >= self.epoch_deadline { + if current >= self.run_ctx.epoch_deadline { return Err(VmError::EpochDeadlineReached { current, - deadline: self.epoch_deadline, + deadline: self.run_ctx.epoch_deadline, }); } Ok(()) @@ -1681,7 +1561,7 @@ impl Vm { } pub(super) fn peek_value(&self) -> VmResult<&Value> { - self.stack.last().ok_or(VmError::StackUnderflow) + self.instance.stack.last().ok_or(VmError::StackUnderflow) } pub(super) fn pop_int(&mut self) -> VmResult { @@ -1705,7 +1585,8 @@ impl Vm { #[inline(always)] pub(super) fn operand_type_hint(&self, ip: usize) -> PackedOperandTypes { - self.operand_type_hints + self.engine + .operand_type_hints .as_deref() .map_or(NO_OPERAND_TYPE_HINT, |hints| hints[ip]) } @@ -1727,57 +1608,68 @@ impl Vm { #[inline(always)] pub(super) fn record_local_type_hint_hit(&mut self) { - self.local_type_hint_hit_count = self.local_type_hint_hit_count.saturating_add(1); + self.instance.local_type_hint_hit_count = + self.instance.local_type_hint_hit_count.saturating_add(1); } #[inline(always)] pub(super) fn record_scalar_superinstruction(&mut self) { - self.scalar_superinstruction_count = self.scalar_superinstruction_count.saturating_add(1); + self.instance.scalar_superinstruction_count = self + .instance + .scalar_superinstruction_count + .saturating_add(1); } #[inline(always)] pub(super) fn record_typed_builtin_fast_path(&mut self) { - self.typed_builtin_fast_path_count = self.typed_builtin_fast_path_count.saturating_add(1); + self.instance.typed_builtin_fast_path_count = self + .instance + .typed_builtin_fast_path_count + .saturating_add(1); } #[inline(always)] pub(super) fn record_projection_fast_path(&mut self) { - self.projection_fast_path_count = self.projection_fast_path_count.saturating_add(1); + self.instance.projection_fast_path_count = + self.instance.projection_fast_path_count.saturating_add(1); } #[inline(always)] pub(super) fn record_generic_builtin_call(&mut self) { - self.generic_builtin_call_count = self.generic_builtin_call_count.saturating_add(1); + self.instance.generic_builtin_call_count = + self.instance.generic_builtin_call_count.saturating_add(1); } #[inline(always)] fn record_operand_hint_hit(&mut self) { - self.operand_hint_hit_count = self.operand_hint_hit_count.saturating_add(1); + self.instance.operand_hint_hit_count = + self.instance.operand_hint_hit_count.saturating_add(1); } #[inline(always)] fn record_operand_hint_miss(&mut self) { - self.operand_hint_miss_count = self.operand_hint_miss_count.saturating_add(1); + self.instance.operand_hint_miss_count = + self.instance.operand_hint_miss_count.saturating_add(1); } #[inline(always)] pub(super) fn unary_not_op(&mut self) -> VmResult<()> { let value = self.pop_bool()?; - self.stack.push(Value::Bool(!value)); + self.instance.stack.push(Value::Bool(!value)); Ok(()) } pub(super) fn int_add_op(&mut self) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(lhs.wrapping_add(rhs))); + self.instance.stack.push(Value::Int(lhs.wrapping_add(rhs))); Ok(()) } pub(super) fn float_add_op(&mut self) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Float(lhs + rhs)); + self.instance.stack.push(Value::Float(lhs + rhs)); Ok(()) } @@ -1793,7 +1685,7 @@ impl Vm { let mut out = String::with_capacity(lhs.len() + rhs.len()); out.push_str(lhs.as_str()); out.push_str(rhs.as_str()); - self.stack.push(Value::string(out)); + self.instance.stack.push(Value::string(out)); Ok(()) } @@ -1808,7 +1700,7 @@ impl Vm { }; let mut out = crate::bytecode::unwrap_or_clone_shared(lhs); out.extend(crate::bytecode::unwrap_or_clone_shared(rhs)); - self.stack.push(Value::bytes(out)); + self.instance.stack.push(Value::bytes(out)); Ok(()) } @@ -1818,7 +1710,7 @@ impl Vm { ) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(op(lhs, rhs)?)); + self.instance.stack.push(Value::Int(op(lhs, rhs)?)); Ok(()) } @@ -1828,40 +1720,40 @@ impl Vm { ) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Float(op(lhs, rhs)?)); + self.instance.stack.push(Value::Float(op(lhs, rhs)?)); Ok(()) } pub(super) fn int_neg_op(&mut self) -> VmResult<()> { let value = self.pop_int()?; - self.stack.push(Value::Int(value.wrapping_neg())); + self.instance.stack.push(Value::Int(value.wrapping_neg())); Ok(()) } pub(super) fn float_neg_op(&mut self) -> VmResult<()> { let value = self.pop_float_exact()?; - self.stack.push(Value::Float(-value)); + self.instance.stack.push(Value::Float(-value)); Ok(()) } pub(super) fn int_eq_op(&mut self) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } pub(super) fn float_eq_op(&mut self) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } pub(super) fn bool_eq_op(&mut self) -> VmResult<()> { let rhs = self.pop_bool()?; let lhs = self.pop_bool()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } @@ -1874,7 +1766,7 @@ impl Vm { Value::String(value) => value, _ => return Err(VmError::TypeMismatch("string")), }; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); Ok(()) } @@ -1883,7 +1775,7 @@ impl Vm { let lhs = self.pop_value()?; match (lhs, rhs) { (Value::Null, Value::Null) => { - self.stack.push(Value::Bool(true)); + self.instance.stack.push(Value::Bool(true)); Ok(()) } _ => Err(VmError::TypeMismatch("null")), @@ -1893,14 +1785,14 @@ impl Vm { pub(super) fn int_compare_op(&mut self, op: impl FnOnce(i64, i64) -> bool) -> VmResult<()> { let rhs = self.pop_int()?; let lhs = self.pop_int()?; - self.stack.push(Value::Bool(op(lhs, rhs))); + self.instance.stack.push(Value::Bool(op(lhs, rhs))); Ok(()) } pub(super) fn float_compare_op(&mut self, op: impl FnOnce(f64, f64) -> bool) -> VmResult<()> { let rhs = self.pop_float_exact()?; let lhs = self.pop_float_exact()?; - self.stack.push(Value::Bool(op(lhs, rhs))); + self.instance.stack.push(Value::Bool(op(lhs, rhs))); Ok(()) } @@ -1909,26 +1801,32 @@ impl Vm { let lhs = self.pop_value()?; match (lhs, rhs) { (Value::Int(lhs), Value::Int(rhs)) => { - self.stack.push(Value::Int(lhs.wrapping_add(rhs))) + self.instance.stack.push(Value::Int(lhs.wrapping_add(rhs))) + } + (Value::Int(lhs), Value::Float(rhs)) => { + self.instance.stack.push(Value::Float(lhs as f64 + rhs)) + } + (Value::Float(lhs), Value::Int(rhs)) => { + self.instance.stack.push(Value::Float(lhs + rhs as f64)) + } + (Value::Float(lhs), Value::Float(rhs)) => { + self.instance.stack.push(Value::Float(lhs + rhs)) } - (Value::Int(lhs), Value::Float(rhs)) => self.stack.push(Value::Float(lhs as f64 + rhs)), - (Value::Float(lhs), Value::Int(rhs)) => self.stack.push(Value::Float(lhs + rhs as f64)), - (Value::Float(lhs), Value::Float(rhs)) => self.stack.push(Value::Float(lhs + rhs)), (Value::String(lhs), Value::String(rhs)) => { let mut out = String::with_capacity(lhs.len() + rhs.len()); out.push_str(lhs.as_str()); out.push_str(rhs.as_str()); - self.stack.push(Value::string(out)); + self.instance.stack.push(Value::string(out)); } (Value::Bytes(lhs), Value::Bytes(rhs)) => { let mut out = crate::bytecode::unwrap_or_clone_shared(lhs); out.extend(crate::bytecode::unwrap_or_clone_shared(rhs)); - self.stack.push(Value::bytes(out)); + self.instance.stack.push(Value::bytes(out)); } (Value::Array(lhs), Value::Array(rhs)) => { let mut out = crate::bytecode::unwrap_or_clone_shared(lhs); out.extend(crate::bytecode::unwrap_or_clone_shared(rhs)); - self.stack.push(Value::array(out)); + self.instance.stack.push(Value::array(out)); } _ => { return Err(VmError::TypeMismatch( @@ -1948,7 +1846,7 @@ impl Vm { let lhs = self.pop_numeric()?; match (lhs, rhs) { (NumericValue::Int(lhs), NumericValue::Int(rhs)) => { - self.stack.push(Value::Int(int_op(lhs, rhs)?)); + self.instance.stack.push(Value::Int(int_op(lhs, rhs)?)); } (lhs, rhs) => { let lhs = match lhs { @@ -1959,7 +1857,7 @@ impl Vm { NumericValue::Int(v) => v as f64, NumericValue::Float(v) => v, }; - self.stack.push(Value::Float(float_op(lhs, rhs)?)); + self.instance.stack.push(Value::Float(float_op(lhs, rhs)?)); } } Ok(()) @@ -1986,7 +1884,7 @@ impl Vm { float_op(lhs, rhs) } }; - self.stack.push(Value::Bool(result)); + self.instance.stack.push(Value::Bool(result)); Ok(()) } @@ -2015,8 +1913,9 @@ impl Vm { index: u8, value: Value, ) -> VmResult<()> { - if self.capture_cells.is_empty() { + if self.instance.capture_cells.is_empty() { let slot = self + .instance .locals .get_mut(absolute) .ok_or(VmError::InvalidLocal(index))?; @@ -2035,7 +1934,7 @@ impl Vm { index: u8, value: Value, ) -> VmResult<()> { - if let Some(cell) = self.capture_cells.get(&absolute).cloned() { + if let Some(cell) = self.instance.capture_cells.get(&absolute).cloned() { if Self::value_references_capture_cell(&value, &cell, &mut HashSet::new())? { return Err(VmError::InvalidFrameState( "callable capture ownership cycle is unsupported", @@ -2047,11 +1946,12 @@ impl Vm { .map_err(|_| VmError::InvalidFrameState("capture cell lock is poisoned"))?; std::mem::replace(&mut *captured, value.clone()) }; - self.locals[absolute] = value; + self.instance.locals[absolute] = value; self.drop_value_with_contract(previous); return Ok(()); } let slot = self + .instance .locals .get_mut(absolute) .ok_or(VmError::InvalidLocal(index))?; @@ -2113,8 +2013,9 @@ impl Vm { pub(crate) fn detach_local_with_drop_contract(&mut self, index: u8) -> VmResult<()> { let absolute = self.absolute_local_index(index)?; - self.capture_cells.remove(&absolute); + self.instance.capture_cells.remove(&absolute); let slot = self + .instance .locals .get_mut(absolute) .ok_or(VmError::InvalidLocal(index))?; @@ -2124,11 +2025,11 @@ impl Vm { } pub(super) fn read_u8(&mut self) -> VmResult { - if self.ip >= self.program.code.len() { + if self.instance.ip >= self.program.code.len() { return Err(VmError::BytecodeBounds); } - let value = self.program.code[self.ip]; - self.ip += 1; + let value = self.program.code[self.instance.ip]; + self.instance.ip += 1; Ok(value) } @@ -2143,12 +2044,13 @@ impl Vm { } pub(super) fn read_bytes(&mut self, count: usize) -> VmResult<[u8; 4]> { - if self.ip + count > self.program.code.len() { + if self.instance.ip + count > self.program.code.len() { return Err(VmError::BytecodeBounds); } let mut buf = [0u8; 4]; - buf[..count].copy_from_slice(&self.program.code[self.ip..self.ip + count]); - self.ip += count; + buf[..count] + .copy_from_slice(&self.program.code[self.instance.ip..self.instance.ip + count]); + self.instance.ip += count; Ok(buf) } @@ -2158,6 +2060,7 @@ impl Vm { } if !self.program.function_regions.is_empty() { let active_prototype = self + .instance .execution_frames .last() .and_then(|frame| frame.prototype_id); @@ -2191,7 +2094,7 @@ impl Vm { return Err(VmError::InvalidBranchTarget { target }); } } - self.ip = target; + self.instance.ip = target; Ok(()) } } @@ -2248,10 +2151,10 @@ impl Vm { ) -> Option { match outcome { ExecOutcome::Continue => {} - ExecOutcome::Halted | ExecOutcome::Waiting(_) => self.last_yield_reason = None, + ExecOutcome::Halted | ExecOutcome::Waiting(_) => self.instance.last_yield_reason = None, ExecOutcome::Yielded => { - if self.last_yield_reason.is_none() { - self.last_yield_reason = Some(VmYieldReason::Host); + if self.instance.last_yield_reason.is_none() { + self.instance.last_yield_reason = Some(VmYieldReason::Host); } } } @@ -2274,7 +2177,7 @@ impl Vm { fn run_fast_interpreter(&mut self, allow_jit: bool) -> VmResult> { loop { - if self.ip >= self.program.code.len() { + if self.instance.ip >= self.program.code.len() { return Err(VmError::BytecodeBounds); } let opcode = self.read_u8()?; @@ -2282,17 +2185,17 @@ impl Vm { match outcome { ExecOutcome::Continue => {} ExecOutcome::Halted => { - self.last_yield_reason = None; + self.instance.last_yield_reason = None; return Ok(Some(VmStatus::Halted)); } ExecOutcome::Yielded => { - if self.last_yield_reason.is_none() { - self.last_yield_reason = Some(VmYieldReason::Host); + if self.instance.last_yield_reason.is_none() { + self.instance.last_yield_reason = Some(VmYieldReason::Host); } return Ok(Some(VmStatus::Yielded)); } ExecOutcome::Waiting(op_id) => { - self.last_yield_reason = None; + self.instance.last_yield_reason = None; return Ok(Some(VmStatus::Waiting(op_id))); } } @@ -2312,28 +2215,28 @@ impl Vm { ) -> VmResult { self.ensure_call_bindings()?; self.sync_jit_non_yielding_host_imports(); - if let Some(waiting) = self.waiting_host_op { - self.last_yield_reason = None; + if let Some(waiting) = self.instance.waiting_host_op { + self.instance.last_yield_reason = None; let status = VmStatus::Waiting(waiting.op_id); self.notify_debugger_status(&mut debugger, status); return Ok(status); } - self.last_yield_reason = None; - if self.epoch_rearm_pending { + self.instance.last_yield_reason = None; + if self.run_ctx.epoch_rearm_pending { self.rearm_epoch_after_yield_if_needed(); } if debugger.is_none() && !self.interruption_enabled() && (!allow_jit || (!self.jit_config().enabled - && (!self.has_aot_program() || self.aot_interpreter_boundary_hit))) + && (!self.has_aot_program() || self.engine.aot_interpreter_boundary_hit))) && let Some(status) = self.run_fast_interpreter(allow_jit)? { return Ok(status); } loop { - if self.epoch_rearm_pending { + if self.run_ctx.epoch_rearm_pending { self.rearm_epoch_after_yield_if_needed(); } if let Some(active_debugger) = debugger.as_deref_mut() { @@ -2342,7 +2245,7 @@ impl Vm { if allow_jit && self.has_aot_program() - && !self.aot_interpreter_boundary_hit + && !self.engine.aot_interpreter_boundary_hit && !self.drop_contract_events_enabled() { let outcome = match self.execute_aot_entry() { @@ -2369,7 +2272,7 @@ impl Vm { continue; } - if self.aot_interpreter_boundary_hit + if self.engine.aot_interpreter_boundary_hit && debugger.is_none() && !self.interruption_enabled() && !self.jit_config().enabled @@ -2380,12 +2283,14 @@ impl Vm { if allow_jit && self.jit_config().enabled - && self.builtin_overrides.is_empty() + && self.host.allow_default_builtin_capabilities + && self.host.allow_default_host_capabilities + && self.host.builtin_overrides.is_empty() && !self.drop_contract_events_enabled() && !self.active_frame_has_shared_capture_cells() { let frame_key = self.active_frame_key(); - let trace_id = if self.jit.callable_frame_is_blocked(frame_key) { + let trace_id = if self.engine.jit.callable_frame_is_blocked(frame_key) { None } else { let stack_depth = self.active_operand_stack_len(); @@ -2393,9 +2298,9 @@ impl Vm { .then(|| self.active_local_types()); let entry_callable_prototypes = self.active_local_callable_prototypes(); let program = &self.program; - self.jit.observe_hot_entry_with_local_types( + self.engine.jit.observe_hot_entry_with_local_types( frame_key, - self.ip, + self.instance.ip, stack_depth, entry_local_types.as_deref(), entry_callable_prototypes.as_deref(), @@ -2428,7 +2333,7 @@ impl Vm { } } - if self.ip >= self.program.code.len() { + if self.instance.ip >= self.program.code.len() { return Err(VmError::BytecodeBounds); } @@ -2487,9 +2392,9 @@ impl Vm { x if x == OpCode::Nop as u8 => {} x if x == OpCode::Ret as u8 => return self.complete_active_frame(), x if x == OpCode::Ldc as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let value = if let Some(value) = self.decoded_ldc_value_at(opcode_ip).cloned() { - self.ip += 4; + self.instance.ip += 4; value } else { let index = self.read_u32()?; @@ -2499,10 +2404,10 @@ impl Vm { .cloned() .ok_or(VmError::InvalidConstant(index))? }; - self.stack.push(value); + self.instance.stack.push(value); } x if x == OpCode::Add as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2527,7 +2432,7 @@ impl Vm { } } x if x == OpCode::Sub as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2547,7 +2452,7 @@ impl Vm { } } x if x == OpCode::Mul as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2567,7 +2472,7 @@ impl Vm { } } x if x == OpCode::Div as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2586,20 +2491,22 @@ impl Vm { x if x == OpCode::Shl as u8 => { let rhs = self.pop_shift_amount()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(lhs.wrapping_shl(rhs))); + self.instance.stack.push(Value::Int(lhs.wrapping_shl(rhs))); } x if x == OpCode::Shr as u8 => { let rhs = self.pop_shift_amount()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(lhs.wrapping_shr(rhs))); + self.instance.stack.push(Value::Int(lhs.wrapping_shr(rhs))); } x if x == OpCode::Lshr as u8 => { let rhs = self.pop_shift_amount()?; let lhs = self.pop_int()?; - self.stack.push(Value::Int(logical_shr_i64(lhs, rhs))); + self.instance + .stack + .push(Value::Int(logical_shr_i64(lhs, rhs))); } x if x == OpCode::Mod as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2618,16 +2525,16 @@ impl Vm { x if x == OpCode::And as u8 => { let rhs = self.pop_bool()?; let lhs = self.pop_bool()?; - self.stack.push(Value::Bool(lhs && rhs)); + self.instance.stack.push(Value::Bool(lhs && rhs)); } x if x == OpCode::Or as u8 => { let rhs = self.pop_bool()?; let lhs = self.pop_bool()?; - self.stack.push(Value::Bool(lhs || rhs)); + self.instance.stack.push(Value::Bool(lhs || rhs)); } x if x == OpCode::Not as u8 => self.unary_not_op()?, x if x == OpCode::Neg as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_UNARY_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2641,15 +2548,17 @@ impl Vm { self.record_operand_hint_miss(); match self.pop_numeric()? { NumericValue::Int(value) => { - self.stack.push(Value::Int(value.wrapping_neg())) + self.instance.stack.push(Value::Int(value.wrapping_neg())) + } + NumericValue::Float(value) => { + self.instance.stack.push(Value::Float(-value)) } - NumericValue::Float(value) => self.stack.push(Value::Float(-value)), } } } } x if x == OpCode::Ceq as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2675,12 +2584,12 @@ impl Vm { self.record_operand_hint_miss(); let rhs = self.pop_value()?; let lhs = self.pop_value()?; - self.stack.push(Value::Bool(lhs == rhs)); + self.instance.stack.push(Value::Bool(lhs == rhs)); } } } x if x == OpCode::Clt as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2697,7 +2606,7 @@ impl Vm { } } x if x == OpCode::Cgt as u8 => { - let ip = self.ip - 1; + let ip = self.instance.ip - 1; match self.operand_type_hint(ip) { INT_INT_OPERAND_TYPE_HINT => { self.record_operand_hint_hit(); @@ -2714,23 +2623,23 @@ impl Vm { } } x if x == OpCode::Br as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let target = if let Some(target) = self.decoded_jump_target_at(opcode_ip) { - self.ip += 4; + self.instance.ip += 4; target } else { self.read_u32()? as usize }; if self.decoded_jump_target_is_valid_at(opcode_ip) { - self.ip = target; + self.instance.ip = target; } else { self.jump_to(target)?; } } x if x == OpCode::Brfalse as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let target = if let Some(target) = self.decoded_jump_target_at(opcode_ip) { - self.ip += 4; + self.instance.ip += 4; target } else { self.read_u32()? as usize @@ -2738,7 +2647,7 @@ impl Vm { let condition = self.pop_bool()?; if !condition { if self.decoded_jump_target_is_valid_at(opcode_ip) { - self.ip = target; + self.instance.ip = target; } else { self.jump_to(target)?; } @@ -2749,12 +2658,12 @@ impl Vm { } x if x == OpCode::Dup as u8 => { let value = self.peek_value()?.clone(); - self.stack.push(value); + self.instance.stack.push(value); } x if x == OpCode::Ldloc as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let index = if let Some(index) = self.decoded_local_index_at(opcode_ip) { - self.ip += 1; + self.instance.ip += 1; index } else { self.read_u8()? @@ -2763,12 +2672,12 @@ impl Vm { return Ok(ExecOutcome::Continue); } let value = self.load_local_value(index)?; - self.stack.push(value); + self.instance.stack.push(value); } x if x == OpCode::Stloc as u8 => { - let opcode_ip = self.ip - 1; + let opcode_ip = self.instance.ip - 1; let index = if let Some(index) = self.decoded_local_index_at(opcode_ip) { - self.ip += 1; + self.instance.ip += 1; index } else { self.read_u8()? @@ -2777,7 +2686,7 @@ impl Vm { self.store_local_with_drop_contract(index, value)?; } x if x == OpCode::Call as u8 => { - let call_ip = self.ip - 1; + let call_ip = self.instance.ip - 1; let index = self.read_u16()?; let argc_u8 = self.read_u8()?; let can_fuse_tail_halt = self.can_fuse_call_ret_pattern(); @@ -2787,13 +2696,13 @@ impl Vm { if self.interruption_enabled() { self.charge_interrupt_tick()?; } - self.ip = self.ip.saturating_add(1); + self.instance.ip = self.instance.ip.saturating_add(1); return self.complete_active_frame(); } } HostCallExecOutcome::Halted => return Ok(ExecOutcome::Halted), HostCallExecOutcome::Yielded => { - self.last_yield_reason = Some(VmYieldReason::Host); + self.instance.last_yield_reason = Some(VmYieldReason::Host); return Ok(ExecOutcome::Yielded); } HostCallExecOutcome::Pending(op_id) => return Ok(ExecOutcome::Waiting(op_id)), @@ -2801,10 +2710,16 @@ impl Vm { } x if x == OpCode::CallValue as u8 => { - let call_ip = self.ip.saturating_sub(1); + let call_ip = self.instance.ip.saturating_sub(1); let argc = self.read_u8()?; return self.execute_call_value(argc, Some(call_ip)); } + x if x == OpCode::CallScript as u8 => { + let call_ip = self.instance.ip.saturating_sub(1); + let prototype_id = self.read_u32()?; + let argc = self.read_u8()?; + return self.execute_call_script(prototype_id, argc, call_ip); + } other => return Err(VmError::InvalidOpcode(other)), } Ok(ExecOutcome::Continue) @@ -2812,7 +2727,8 @@ impl Vm { pub fn resume(&mut self) -> VmResult { let allow_jit = !matches!( - self.execution_frames + self.instance + .execution_frames .last() .map(|frame| &frame.continuation), Some(FrameContinuation::ReturnToHost) @@ -2821,16 +2737,16 @@ impl Vm { } pub fn stack(&self) -> &[Value] { - &self.stack + &self.instance.stack } pub fn locals(&self) -> &[Value] { - &self.locals + &self.instance.locals } pub fn set_local(&mut self, index: u8, value: Value) -> VmResult<()> { self.store_local_with_drop_contract(index, value)?; - let config = *self.jit.config(); + let config = *self.engine.jit.config(); self.set_jit_config(config); Ok(()) } @@ -2840,22 +2756,22 @@ impl Vm { } pub fn bound_function_count(&self) -> usize { - self.host_functions.len() + self.host.host_functions.len() } pub fn has_bound_function(&self, name: &str) -> bool { - self.host_function_symbols.contains_key(name) + self.host.host_function_symbols.contains_key(name) } pub fn ip(&self) -> usize { - self.ip + self.instance.ip } pub(super) fn owns_callable(&self, value: &Value) -> bool { let Value::Callable(target) = value else { return false; }; - self.owned_callables.iter().any(|owned| { + self.instance.owned_callables.iter().any(|owned| { owned .upgrade() .is_some_and(|owned| Arc::ptr_eq(&owned, target)) @@ -2872,6 +2788,7 @@ impl Vm { VmError::HostError(format!("unknown exported script function '{name}'")) })?; let value = self + .instance .locals .get(exported.local_slot as usize) .cloned() @@ -2892,7 +2809,7 @@ impl Vm { } pub fn call_depth(&self) -> usize { - self.call_depth + self.instance.call_depth } pub fn queue_callable(&mut self, callable: Value, args: Vec) -> VmResult<()> { @@ -2905,13 +2822,13 @@ impl Vm { args: Vec, subscription: Option>, ) -> VmResult<()> { - if self.shutdown { + if self.instance.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); } if !matches!(&callable, Value::Callable(_)) { return Err(VmError::InvalidCallable); } - self.queued_callables.push_back(QueuedCallable { + self.instance.queued_callables.push_back(QueuedCallable { callable, args, subscription, @@ -2920,23 +2837,23 @@ impl Vm { } pub fn queued_callable_count(&self) -> usize { - self.queued_callables.len() + self.instance.queued_callables.len() } pub fn drain_callable_queue(&mut self) -> VmResult> { - if self.draining_queued_callables { + if self.instance.draining_queued_callables { return Err(VmError::InvalidFrameState( "callable queue is already being drained", )); } - if !self.execution_frames.is_empty() { + if !self.instance.execution_frames.is_empty() { return Err(VmError::InvalidFrameState( "queued callables can only run after the root frame halts", )); } - self.draining_queued_callables = true; - let mut results = Vec::with_capacity(self.queued_callables.len()); - while let Some(queued) = self.queued_callables.pop_front() { + self.instance.draining_queued_callables = true; + let mut results = Vec::with_capacity(self.instance.queued_callables.len()); + while let Some(queued) = self.instance.queued_callables.pop_front() { if queued .subscription .as_ref() @@ -2946,9 +2863,9 @@ impl Vm { } match self.start_callable(queued.callable, &queued.args) { Ok(VmStatus::Halted) => { - let Some(result) = self.host_return.take() else { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + let Some(result) = self.instance.host_return.take() else { + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(VmError::InvalidFrameState( "queued invocation completed without a result", )); @@ -2956,84 +2873,78 @@ impl Vm { results.push(result); } Ok(VmStatus::Yielded) => { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(VmError::InvalidFrameState( "queued invocation yielded; resume it before draining again", )); } Ok(VmStatus::Waiting(_)) => { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(VmError::InvalidFrameState( "queued invocation is waiting; resume it before draining again", )); } Err(err) => { - self.completed_callable_results.extend(results); - self.draining_queued_callables = false; + self.instance.completed_callable_results.extend(results); + self.instance.draining_queued_callables = false; return Err(err); } } } - self.draining_queued_callables = false; + self.instance.draining_queued_callables = false; Ok(results) } pub fn shutdown(&mut self) { self.invalidate_callback_registries(); self.cancel_waiting_host_op(); - self.queued_callables.clear(); - self.completed_callable_results.clear(); - self.owned_callables.clear(); - self.draining_queued_callables = false; + self.instance.queued_callables.clear(); + self.instance.completed_callable_results.clear(); + self.instance.owned_callables.clear(); + self.instance.draining_queued_callables = false; self.clear_stack_with_drop_contract(); - self.capture_cells.clear(); - self.shared_capture_slots.clear(); + self.instance.capture_cells.clear(); + self.instance.shared_capture_slots.clear(); self.clear_locals_with_drop_contract(); - self.execution_frames.clear(); - self.active_local_base_cache = 0; - self.active_operand_stack_base_cache = 0; - self.call_depth = 0; - self.host_return = None; - self.waiting_host_op = None; + self.instance.execution_frames.clear(); + self.instance.active_local_base_cache = 0; + self.instance.active_operand_stack_base_cache = 0; + self.instance.call_depth = 0; + self.instance.host_return = None; + self.instance.waiting_host_op = None; crate::builtins::runtime::close_all_handles(self); - self.shutdown = true; + self.instance.shutdown = true; } pub(super) fn register_callback_registry(&mut self, active: &Arc) { - self.callback_registry_flags.push(Arc::downgrade(active)); + self.instance.register_callback_registry(active); } fn invalidate_callback_registries(&mut self) { - for active in self - .callback_registry_flags - .drain(..) - .filter_map(|flag| flag.upgrade()) - { - active.store(false, Ordering::Release); - } + self.instance.invalidate_callback_registries(); } pub fn start_callable(&mut self, callable: Value, args: &[Value]) -> VmResult { - if self.shutdown { + if self.instance.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); } if !matches!(&callable, Value::Callable(_)) { return Err(VmError::InvalidCallable); } - if !self.execution_frames.is_empty() { + if !self.instance.execution_frames.is_empty() { return Err(VmError::InvalidFrameState( "host invocation requires a halted VM", )); } let argc = u8::try_from(args.len()) .map_err(|_| VmError::InvalidFrameState("too many arguments"))?; - let stack_base = self.stack.len(); - let frame_count = self.execution_frames.len(); - self.stack.push(callable); - self.stack.extend_from_slice(args); - self.host_return = None; + let stack_base = self.instance.stack.len(); + let frame_count = self.instance.execution_frames.len(); + self.instance.stack.push(callable); + self.instance.stack.extend_from_slice(args); + self.instance.host_return = None; let outcome = match self.execute_call_value(argc, None) { Ok(outcome) => outcome, Err(error) => { @@ -3041,10 +2952,10 @@ impl Vm { return Err(error); } }; - if self.execution_frames.len() == frame_count { + if self.instance.execution_frames.len() == frame_count { let result = match outcome { ExecOutcome::Continue | ExecOutcome::Halted => { - self.stack.pop().unwrap_or(Value::Null) + self.instance.stack.pop().unwrap_or(Value::Null) } ExecOutcome::Yielded => { self.abort_host_invocation(stack_base, frame_count); @@ -3059,11 +2970,11 @@ impl Vm { )); } }; - self.stack.truncate(stack_base); - self.host_return = Some(result); + self.instance.stack.truncate(stack_base); + self.instance.host_return = Some(result); return Ok(VmStatus::Halted); } - if let Some(frame) = self.execution_frames.last_mut() { + if let Some(frame) = self.instance.execution_frames.last_mut() { frame.continuation = FrameContinuation::ReturnToHost; } match self.run_internal(None, false) { @@ -3076,12 +2987,16 @@ impl Vm { } pub fn invoke_callable(&mut self, callable: Value, args: &[Value]) -> VmResult { - let stack_base = self.stack.len(); - let frame_count = self.execution_frames.len(); + let stack_base = self.instance.stack.len(); + let frame_count = self.instance.execution_frames.len(); match self.start_callable(callable, args)? { - VmStatus::Halted => self.host_return.take().ok_or(VmError::InvalidFrameState( - "host invocation completed without a result", - )), + VmStatus::Halted => self + .instance + .host_return + .take() + .ok_or(VmError::InvalidFrameState( + "host invocation completed without a result", + )), VmStatus::Yielded => { self.abort_host_invocation(stack_base, frame_count); Err(VmError::InvalidFrameState("host invocation yielded")) @@ -3094,53 +3009,64 @@ impl Vm { } fn abort_host_invocation(&mut self, stack_base: usize, frame_count: usize) { - while self.execution_frames.len() > frame_count { - let Some(frame) = self.execution_frames.pop() else { + while self.instance.execution_frames.len() > frame_count { + let Some(frame) = self.instance.execution_frames.pop() else { break; }; let frame_end = frame.local_base.saturating_add(frame.local_count); - self.capture_cells + self.instance + .capture_cells .retain(|absolute, _| *absolute < frame.local_base || *absolute >= frame_end); - self.shared_capture_slots + self.instance + .shared_capture_slots .retain(|absolute| *absolute < frame.local_base || *absolute >= frame_end); - if frame.local_base <= self.locals.len() { - let drained = self.locals.drain(frame.local_base..).collect::>(); + if frame.local_base <= self.instance.locals.len() { + let drained = self + .instance + .locals + .drain(frame.local_base..) + .collect::>(); for value in drained { self.drop_value_with_contract(value); } } } - self.active_local_base_cache = self + self.instance.active_local_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.local_base) .unwrap_or(0); - self.active_operand_stack_base_cache = self + self.instance.active_operand_stack_base_cache = self + .instance .execution_frames .last() .map(|frame| frame.operand_stack_base) .unwrap_or(0); - while self.stack.len() > stack_base { - if let Some(value) = self.stack.pop() { + while self.instance.stack.len() > stack_base { + if let Some(value) = self.instance.stack.pop() { self.drop_value_with_contract(value); } } - self.call_depth = self.script_frame_depth(); - self.host_return = None; + self.instance.call_depth = self.script_frame_depth(); + self.instance.host_return = None; self.cancel_waiting_host_op(); - self.last_yield_reason = None; - self.map_iterators - .truncate(self.call_depth.saturating_add(1)); + self.instance.last_yield_reason = None; + self.instance + .map_iterators + .truncate(self.instance.call_depth.saturating_add(1)); } pub fn take_callable_result(&mut self) -> Option { - self.completed_callable_results + self.instance + .completed_callable_results .pop_front() - .or_else(|| self.host_return.take()) + .or_else(|| self.instance.host_return.take()) } pub fn execution_frames(&self) -> Vec { - self.execution_frames + self.instance + .execution_frames .iter() .map(|frame| VmExecutionFrameSnapshot { continuation: match frame.continuation { diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index ff692ba3..c1d38fb8 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -316,6 +316,14 @@ pub(crate) fn enter_call_value_inherited_entry_address() -> usize { pd_vm_native_enter_call_value_inherited as *const () as usize } +pub(crate) fn enter_call_script_entry_address() -> usize { + pd_vm_native_enter_call_script as *const () as usize +} + +pub(crate) fn enter_call_script_inherited_entry_address() -> usize { + pd_vm_native_enter_call_script_inherited as *const () as usize +} + pub(crate) fn leave_frame_entry_address() -> usize { pd_vm_native_leave_frame as *const () as usize } @@ -393,12 +401,12 @@ pub(crate) fn non_yielding_i64_host_call_entry_address() -> usize { } pub(crate) fn helper_entry_offset() -> i32 { - i32::try_from(std::mem::offset_of!(Vm, native_helper_fn)) + i32::try_from(std::mem::offset_of!(Vm, engine.native_helper_fn)) .expect("Vm::native_helper_fn offset must fit i32") } pub(crate) fn interrupt_helper_entry_offset() -> i32 { - i32::try_from(std::mem::offset_of!(Vm, native_interrupt_helper_fn)) + i32::try_from(std::mem::offset_of!(Vm, engine.native_interrupt_helper_fn)) .expect("Vm::native_interrupt_helper_fn offset must fit i32") } @@ -780,10 +788,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_exit_state( ip: usize, ) -> i32 { run_step(vm, "restore_exit_state", |vm| { - if locals_len != vm.locals.len() { + if locals_len != vm.instance.locals.len() { return Err(VmError::JitNative(format!( "native exit restore locals length mismatch: expected {}, got {}", - vm.locals.len(), + vm.instance.locals.len(), locals_len ))); } @@ -799,10 +807,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_exit_state( } vm.clear_stack_with_drop_contract(); - vm.stack.reserve(stack_len); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } for index in 0..locals_len { @@ -819,13 +827,14 @@ pub(crate) extern "C" fn pd_vm_native_restore_exit_state( } fn native_frame_state(vm: &Vm) -> VmResult { - let frame = vm.execution_frames.last(); + let frame = vm.instance.execution_frames.last(); let operand_stack_base = frame.map(|frame| frame.operand_stack_base).unwrap_or(0); let local_base = frame.map(|frame| frame.local_base).unwrap_or(0); let local_count = frame .map(|frame| frame.local_count) - .unwrap_or(vm.locals.len()); + .unwrap_or(vm.instance.locals.len()); let active_stack_len = vm + .instance .stack .len() .checked_sub(operand_stack_base) @@ -847,7 +856,7 @@ fn native_frame_state(vm: &Vm) -> VmResult { active_stack_len, local_base, local_count, - frame_depth: vm.call_depth, + frame_depth: vm.instance.call_depth, continuation_kind, }) } @@ -900,7 +909,7 @@ fn write_inherited_state_packet(vm: &Vm, packet: *mut u8) -> VmResult<()> { packet .add(INHERITED_STATE_TARGET_IP_OFFSET as usize) .cast::() - .write(vm.ip); + .write(vm.instance.ip); packet .add(INHERITED_STATE_VALUE_COUNT_OFFSET as usize) .cast::() @@ -908,11 +917,11 @@ fn write_inherited_state_packet(vm: &Vm, packet: *mut u8) -> VmResult<()> { let values = packet .add(INHERITED_STATE_VALUES_OFFSET as usize) .cast::<*const Value>(); - let stack = vm.stack.as_ptr().add(state.operand_stack_base); + let stack = vm.instance.stack.as_ptr().add(state.operand_stack_base); for index in 0..state.active_stack_len { values.add(index).write(stack.add(index)); } - let locals = vm.locals.as_ptr().add(state.local_base); + let locals = vm.instance.locals.as_ptr().add(state.local_base); for index in 0..state.local_count { values .add(state.active_stack_len + index) @@ -957,13 +966,13 @@ fn native_enter_call_value( .map_err(|_| VmError::InvalidFrameState("native call ip out of range"))?; let resume_ip = usize::try_from(resume_ip) .map_err(|_| VmError::InvalidFrameState("native resume ip out of range"))?; - if vm.ip != call_ip { + if vm.instance.ip != call_ip { vm.jump_to(call_ip)?; } if resume_ip > vm.program.code.len() { return Err(VmError::BytecodeBounds); } - vm.ip = resume_ip; + vm.instance.ip = resume_ip; let status = match vm.execute_call_value(argc, Some(call_ip))? { ExecOutcome::Continue => STATUS_LINKED_CONTINUE, ExecOutcome::Halted => STATUS_HALTED, @@ -1004,6 +1013,78 @@ pub(crate) extern "C" fn pd_vm_native_enter_call_value_inherited( }) } +fn native_enter_call_script( + vm: &mut Vm, + prototype_id: i64, + argc: i64, + call_ip: i64, + resume_ip: i64, + inherited_state: *mut u8, +) -> VmResult { + let prototype_id = u32::try_from(prototype_id) + .map_err(|_| VmError::InvalidFrameState("native call-script prototype id out of range"))?; + let argc = u8::try_from(argc) + .map_err(|_| VmError::InvalidFrameState("native call-script argc out of range"))?; + let call_ip = usize::try_from(call_ip) + .map_err(|_| VmError::InvalidFrameState("native call-script ip out of range"))?; + let resume_ip = usize::try_from(resume_ip) + .map_err(|_| VmError::InvalidFrameState("native call-script resume ip out of range"))?; + if vm.instance.ip != call_ip { + vm.jump_to(call_ip)?; + } + if resume_ip > vm.program.code.len() { + return Err(VmError::BytecodeBounds); + } + vm.instance.ip = resume_ip; + let status = match vm.execute_call_script(prototype_id, argc, call_ip)? { + ExecOutcome::Continue => STATUS_LINKED_CONTINUE, + ExecOutcome::Halted => STATUS_HALTED, + ExecOutcome::Yielded => STATUS_YIELDED, + ExecOutcome::Waiting(_) => STATUS_WAITING, + }; + if status == STATUS_LINKED_CONTINUE { + if vm.active_frame_has_shared_capture_cells() { + return Ok(STATUS_CONTINUE); + } + if !inherited_state.is_null() { + write_inherited_state_packet(vm, inherited_state)?; + } + } + Ok(status) +} + +pub(crate) extern "C" fn pd_vm_native_enter_call_script( + vm: *mut Vm, + prototype_id: i64, + argc: i64, + call_ip: i64, + resume_ip: i64, +) -> i32 { + run_step(vm, "enter_call_script", |vm| { + native_enter_call_script( + vm, + prototype_id, + argc, + call_ip, + resume_ip, + std::ptr::null_mut(), + ) + }) +} + +pub(crate) extern "C" fn pd_vm_native_enter_call_script_inherited( + vm: *mut Vm, + prototype_id: i64, + argc: i64, + call_ip: i64, + resume_ip: i64, + inherited_state: *mut u8, +) -> i32 { + run_step(vm, "enter_call_script", |vm| { + native_enter_call_script(vm, prototype_id, argc, call_ip, resume_ip, inherited_state) + }) +} + fn native_leave_frame(vm: &mut Vm, ret_ip: i64, inherited_state: *mut u8) -> VmResult { let ret_ip = usize::try_from(ret_ip) .map_err(|_| VmError::InvalidFrameState("native ret ip out of range"))?; @@ -1067,17 +1148,17 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_exit_state( let expected_locals_len = local_base .checked_add(locals_len) .ok_or_else(|| VmError::JitNative("native active local length overflow".to_string()))?; - if expected_locals_len != vm.locals.len() { + if expected_locals_len != vm.instance.locals.len() { return Err(VmError::JitNative(format!( "native active exit restore locals length mismatch: expected {}, got {}", - vm.locals.len(), + vm.instance.locals.len(), expected_locals_len ))); } - if stack_base > vm.stack.len() { + if stack_base > vm.instance.stack.len() { return Err(VmError::JitNative(format!( "native active stack base {stack_base} exceeds stack length {}", - vm.stack.len() + vm.instance.stack.len() ))); } if stack_len != 0 && stack_src.is_null() { @@ -1091,11 +1172,11 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_exit_state( )); } - vm.stack.truncate(stack_base); - vm.stack.reserve(stack_len); + vm.instance.stack.truncate(stack_base); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } for index in 0..locals_len { @@ -1145,10 +1226,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_sparse_exit_state( "native sparse exit restore local index out of range".to_string(), ) })?; - if local_index_usize >= vm.locals.len() { + if local_index_usize >= vm.instance.locals.len() { return Err(VmError::JitNative(format!( "native sparse exit restore local index {local_index} out of range for {} locals", - vm.locals.len() + vm.instance.locals.len() ))); } let local_index = u8::try_from(local_index).map_err(|_| { @@ -1165,10 +1246,10 @@ pub(crate) extern "C" fn pd_vm_native_restore_sparse_exit_state( } vm.clear_stack_with_drop_contract(); - vm.stack.reserve(stack_len); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } for (compact_index, local_index) in validated_indices.into_iter().enumerate() { @@ -1212,29 +1293,29 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_sparse_exit_state( // while the sparse exit metadata is built. let stack_base = vm.active_operand_stack_base(); - if stack_base > vm.stack.len() { + if stack_base > vm.instance.stack.len() { return Err(VmError::JitNative(format!( "native active sparse stack base {stack_base} exceeds stack length {}", - vm.stack.len() + vm.instance.stack.len() ))); } - vm.stack.truncate(stack_base); - vm.stack.reserve(stack_len); + vm.instance.stack.truncate(stack_base); + vm.instance.stack.reserve(stack_len); for index in 0..stack_len { let value = unsafe { std::ptr::read(stack_src.add(index)) }; - vm.stack.push(value); + vm.instance.stack.push(value); } - if vm.capture_cells.is_empty() { + if vm.instance.capture_cells.is_empty() { let local_base = vm.active_local_base(); - let count_drop_events = vm.drop_contract_events_enabled; + let count_drop_events = vm.instance.drop_contract_events_enabled; for compact_index in 0..dirty_local_count { let local_index = unsafe { *dirty_local_indices.add(compact_index) } as usize; debug_assert!(local_index < 256); let absolute = local_base + local_index; - debug_assert!(absolute < vm.locals.len()); + debug_assert!(absolute < vm.instance.locals.len()); let value = unsafe { std::ptr::read(dirty_local_values.add(compact_index)) }; - let slot = unsafe { vm.locals.get_unchecked_mut(absolute) }; + let slot = unsafe { vm.instance.locals.get_unchecked_mut(absolute) }; let previous = std::mem::replace(slot, value); if count_drop_events { vm.count_value_drop_contract(&previous); @@ -1253,7 +1334,7 @@ pub(crate) extern "C" fn pd_vm_native_restore_active_sparse_exit_state( if ip >= vm.program.code.len() { return Err(VmError::InvalidBranchTarget { target: ip }); } - vm.ip = ip; + vm.instance.ip = ip; Ok(STATUS_CONTINUE) }) } @@ -1281,9 +1362,9 @@ pub(crate) extern "C" fn pd_vm_native_restore_virtual_frame( "virtual frame restore received null locals buffer".to_string(), )); } - if vm.call_depth >= vm.max_script_call_depth { + if vm.instance.call_depth >= vm.instance.max_script_call_depth { return Err(VmError::CallStackOverflow { - limit: vm.max_script_call_depth, + limit: vm.instance.max_script_call_depth, }); } let prototype = vm @@ -1315,8 +1396,17 @@ pub(crate) extern "C" fn pd_vm_native_restore_virtual_frame( "virtual frame local count does not match prototype", )); } - if call_ip.saturating_add(2) != return_ip - || vm.program.code.get(call_ip).copied() != Some(crate::OpCode::CallValue as u8) + // The virtual frame continuation must resume exactly after the call + // instruction that produced it: `CallValue` carries a one-byte + // `argc` operand, `CallScript` a five-byte `(prototype_id, argc)` + // operand. + let call_instruction_len = match vm.program.code.get(call_ip).copied() { + Some(opcode) if opcode == crate::OpCode::CallValue as u8 => 2, + Some(opcode) if opcode == crate::OpCode::CallScript as u8 => 6, + _ => 0, + }; + if call_instruction_len == 0 + || call_ip.saturating_add(call_instruction_len) != return_ip || return_ip > vm.program.code.len() || resume_ip < function.entry_ip as usize || resume_ip >= function.end_ip as usize @@ -1326,29 +1416,31 @@ pub(crate) extern "C" fn pd_vm_native_restore_virtual_frame( )); } - let operand_stack_base = vm.stack.len(); - let local_base = vm.locals.len(); - vm.stack.reserve(stack_len); - vm.locals.reserve(locals_len); + let operand_stack_base = vm.instance.stack.len(); + let local_base = vm.instance.locals.len(); + vm.instance.stack.reserve(stack_len); + vm.instance.locals.reserve(locals_len); for index in 0..stack_len { - vm.stack + vm.instance + .stack .push(unsafe { std::ptr::read(stack_src.add(index)) }); } for index in 0..locals_len { - vm.locals + vm.instance + .locals .push(unsafe { std::ptr::read(locals_src.add(index)) }); } - vm.execution_frames.push(ExecutionFrame { + vm.instance.execution_frames.push(ExecutionFrame { continuation: FrameContinuation::ResumeBytecode { return_ip }, operand_stack_base, local_base, local_count: locals_len, prototype_id: Some(prototype_id), }); - vm.active_local_base_cache = local_base; - vm.active_operand_stack_base_cache = operand_stack_base; - vm.call_depth = vm.script_frame_depth(); - vm.ip = resume_ip; + vm.instance.active_local_base_cache = local_base; + vm.instance.active_operand_stack_base_cache = operand_stack_base; + vm.instance.call_depth = vm.script_frame_depth(); + vm.instance.ip = resume_ip; Ok(STATUS_CONTINUE) }) } @@ -1662,10 +1754,11 @@ fn call_non_yielding_host_value( expected_return_type: Option, ) -> VmResult { let resolved = *vm + .host .resolved_calls .get(import) .ok_or(VmError::InvalidCall(import as u16))?; - let function = match vm.host_functions.get(usize::from(resolved)) { + let function = match vm.host.host_functions.get(usize::from(resolved)) { Some(VmHostFunction::ArgsStaticNonYielding(function)) => *function, _ => { return Err(VmError::JitNative( @@ -1673,9 +1766,9 @@ fn call_non_yielding_host_value( )); } }; - vm.call_depth = vm.call_depth.saturating_add(1); + vm.instance.call_depth = vm.instance.call_depth.saturating_add(1); let outcome = function(args); - vm.call_depth = vm.call_depth.saturating_sub(1); + vm.instance.call_depth = vm.instance.call_depth.saturating_sub(1); outcome .and_then(crate::vm::host::require_non_yielding_host_value) .and_then(|value| { @@ -1809,7 +1902,7 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, .get(index as usize) .cloned() .ok_or(VmError::InvalidConstant(index))?; - vm.stack.push(value); + vm.instance.stack.push(value); Ok(STATUS_CONTINUE) } OP_ADD => { @@ -1841,34 +1934,41 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, OP_SHL => { let rhs = vm.pop_shift_amount()?; let lhs = vm.pop_int()?; - vm.stack + vm.instance + .stack .push(crate::bytecode::Value::Int(lhs.wrapping_shl(rhs))); Ok(STATUS_CONTINUE) } OP_SHR => { let rhs = vm.pop_shift_amount()?; let lhs = vm.pop_int()?; - vm.stack + vm.instance + .stack .push(crate::bytecode::Value::Int(lhs.wrapping_shr(rhs))); Ok(STATUS_CONTINUE) } OP_LSHR => { let rhs = vm.pop_shift_amount()?; let lhs = vm.pop_int()?; - vm.stack + vm.instance + .stack .push(crate::bytecode::Value::Int(logical_shr_i64(lhs, rhs))); Ok(STATUS_CONTINUE) } OP_AND => { let rhs = vm.pop_bool()?; let lhs = vm.pop_bool()?; - vm.stack.push(crate::bytecode::Value::Bool(lhs && rhs)); + vm.instance + .stack + .push(crate::bytecode::Value::Bool(lhs && rhs)); Ok(STATUS_CONTINUE) } OP_OR => { let rhs = vm.pop_bool()?; let lhs = vm.pop_bool()?; - vm.stack.push(crate::bytecode::Value::Bool(lhs || rhs)); + vm.instance + .stack + .push(crate::bytecode::Value::Bool(lhs || rhs)); Ok(STATUS_CONTINUE) } OP_NOT => { @@ -1879,18 +1979,22 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, let value = vm.pop_numeric()?; match value { NumericValue::Int(value) => vm + .instance .stack .push(crate::bytecode::Value::Int(value.wrapping_neg())), - NumericValue::Float(value) => { - vm.stack.push(crate::bytecode::Value::Float(-value)) - } + NumericValue::Float(value) => vm + .instance + .stack + .push(crate::bytecode::Value::Float(-value)), } Ok(STATUS_CONTINUE) } OP_CEQ => { let rhs = vm.pop_value()?; let lhs = vm.pop_value()?; - vm.stack.push(crate::bytecode::Value::Bool(lhs == rhs)); + vm.instance + .stack + .push(crate::bytecode::Value::Bool(lhs == rhs)); Ok(STATUS_CONTINUE) } OP_CLT => { @@ -1907,18 +2011,19 @@ pub(crate) extern "C" fn pd_vm_native_step(vm: *mut Vm, op: i64, a: i64, b: i64, } OP_DUP => { let value = vm.peek_value()?.clone(); - vm.stack.push(value); + vm.instance.stack.push(value); Ok(STATUS_CONTINUE) } OP_LDLOC => { let index = u8::try_from(a) .map_err(|_| VmError::JitNative("ldloc index out of range".to_string()))?; let value = vm + .instance .locals .get(index as usize) .cloned() .ok_or(VmError::InvalidLocal(index))?; - vm.stack.push(value); + vm.instance.stack.push(value); Ok(STATUS_CONTINUE) } OP_STLOC => { @@ -2046,11 +2151,11 @@ mod tests { let mut vm = Vm::new(virtual_frame_program()); let locals = [Value::Int(7)]; let before = ( - vm.ip, - vm.stack.len(), - vm.locals.len(), - vm.execution_frames.len(), - vm.call_depth, + vm.instance.ip, + vm.instance.stack.len(), + vm.instance.locals.len(), + vm.instance.execution_frames.len(), + vm.instance.call_depth, ); let status = pd_vm_native_restore_virtual_frame( &mut vm, @@ -2067,11 +2172,11 @@ mod tests { assert_eq!( before, ( - vm.ip, - vm.stack.len(), - vm.locals.len(), - vm.execution_frames.len(), - vm.call_depth, + vm.instance.ip, + vm.instance.stack.len(), + vm.instance.locals.len(), + vm.instance.execution_frames.len(), + vm.instance.call_depth, ) ); let _ = take_bridge_error(); @@ -2093,11 +2198,11 @@ mod tests { locals.len(), ); assert_eq!(status, STATUS_CONTINUE); - assert_eq!(vm.ip, 2); - assert_eq!(vm.call_depth, 1); - assert_eq!(vm.execution_frames.len(), 2); - assert_eq!(vm.locals.last(), Some(&Value::Int(7))); - let frame = vm.execution_frames.last().unwrap(); + assert_eq!(vm.instance.ip, 2); + assert_eq!(vm.instance.call_depth, 1); + assert_eq!(vm.instance.execution_frames.len(), 2); + assert_eq!(vm.instance.locals.last(), Some(&Value::Int(7))); + let frame = vm.instance.execution_frames.last().unwrap(); assert_eq!(frame.prototype_id, Some(0)); assert_eq!(frame.local_count, 1); assert_eq!( @@ -2135,24 +2240,26 @@ mod tests { let program = crate::Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]).with_local_count(2); let mut vm = Vm::new(program); - vm.stack = vec![Value::Int(10), Value::Int(20)]; - vm.locals = vec![ + vm.instance.stack = vec![Value::Int(10), Value::Int(20)]; + vm.instance.locals = vec![ Value::Int(1), Value::Int(2), Value::Int(3), Value::Int(4), Value::Int(5), ]; - vm.execution_frames.push(crate::vm::ExecutionFrame { - continuation: FrameContinuation::ResumeBytecode { return_ip: 0 }, - operand_stack_base: 1, - local_base: 2, - local_count: 3, - prototype_id: Some(7), - }); - vm.active_local_base_cache = 2; - vm.active_operand_stack_base_cache = 1; - vm.call_depth = 1; + vm.instance + .execution_frames + .push(crate::vm::ExecutionFrame { + continuation: FrameContinuation::ResumeBytecode { return_ip: 0 }, + operand_stack_base: 1, + local_base: 2, + local_count: 3, + prototype_id: Some(7), + }); + vm.instance.active_local_base_cache = 2; + vm.instance.active_operand_stack_base_cache = 1; + vm.instance.call_depth = 1; let mut state = MaybeUninit::::uninit(); assert_eq!( @@ -2188,9 +2295,9 @@ mod tests { ); std::mem::forget(stack); std::mem::forget(locals); - assert_eq!(vm.stack, vec![Value::Int(10), Value::Int(99)]); + assert_eq!(vm.instance.stack, vec![Value::Int(10), Value::Int(99)]); assert_eq!( - vm.locals, + vm.instance.locals, vec![ Value::Int(1), Value::Int(2), @@ -2218,11 +2325,11 @@ mod tests { std::mem::forget(sparse_stack); std::mem::forget(dirty_values); assert_eq!( - vm.stack, + vm.instance.stack, vec![Value::Int(10), Value::Int(77), Value::Int(88)] ); assert_eq!( - vm.locals, + vm.instance.locals, vec![ Value::Int(1), Value::Int(2), @@ -2265,22 +2372,22 @@ mod tests { .expect("bind callable"); assert!(matches!(callable, Value::Callable(_))); - vm.stack.extend([callable, Value::Int(41)]); + vm.instance.stack.extend([callable, Value::Int(41)]); assert_eq!( pd_vm_native_enter_call_value(&mut vm, 1, call_ip as i64, resume_ip as i64,), STATUS_LINKED_CONTINUE ); - assert_eq!(vm.call_depth, 1); - assert_eq!(vm.ip, function.entry_ip as usize); + assert_eq!(vm.instance.call_depth, 1); + assert_eq!(vm.instance.ip, function.entry_ip as usize); - vm.stack.push(Value::Int(42)); + vm.instance.stack.push(Value::Int(42)); assert_eq!( pd_vm_native_leave_frame(&mut vm, ret_ip as i64), STATUS_LINKED_CONTINUE ); - assert_eq!(vm.call_depth, 0); - assert_eq!(vm.ip, resume_ip); - assert_eq!(vm.stack, vec![Value::Int(42)]); + assert_eq!(vm.instance.call_depth, 0); + assert_eq!(vm.instance.ip, resume_ip); + assert_eq!(vm.instance.stack, vec![Value::Int(42)]); } #[test] @@ -2314,7 +2421,7 @@ mod tests { vm.set_local(0, Value::Int(17)).expect("scalar local"); vm.set_local(1, Value::String(preserved.clone())) .expect("heap local"); - vm.stack.push(Value::Int(99)); + vm.instance.stack.push(Value::Int(99)); let status = pd_vm_native_restore_sparse_exit_state( &mut vm, @@ -2342,7 +2449,7 @@ mod tests { crate::Program::new(Vec::new(), vec![crate::OpCode::Ret as u8]).with_local_count(1); let mut vm = Vm::new(program); vm.set_local(0, Value::Int(17)).expect("initial local"); - vm.stack.push(Value::Int(23)); + vm.instance.stack.push(Value::Int(23)); let local_value = Value::Int(99); let null_indices = pd_vm_native_restore_sparse_exit_state( diff --git a/src/vm/native/codegen.rs b/src/vm/native/codegen.rs index 72d71f48..21030339 100644 --- a/src/vm/native/codegen.rs +++ b/src/vm/native/codegen.rs @@ -60,6 +60,29 @@ pub(crate) fn enter_call_value_inherited_signature( sig } +#[cfg(feature = "cranelift-jit")] +pub(crate) fn enter_call_script_signature( + pointer_type: cranelift_codegen::ir::Type, + call_conv: cranelift_codegen::isa::CallConv, +) -> Signature { + let mut sig = Signature::new(call_conv); + sig.params.push(AbiParam::new(pointer_type)); + // prototype_id:u32, argc:u8, call_ip:usize, resume_ip:usize + sig.params.extend((0..4).map(|_| AbiParam::new(types::I64))); + sig.returns.push(AbiParam::new(types::I32)); + sig +} + +#[cfg(feature = "cranelift-jit")] +pub(crate) fn enter_call_script_inherited_signature( + pointer_type: cranelift_codegen::ir::Type, + call_conv: cranelift_codegen::isa::CallConv, +) -> Signature { + let mut sig = enter_call_script_signature(pointer_type, call_conv); + sig.params.push(AbiParam::new(pointer_type)); + sig +} + #[cfg(feature = "cranelift-jit")] pub(crate) fn leave_frame_signature( pointer_type: cranelift_codegen::ir::Type, diff --git a/src/vm/native/layout.rs b/src/vm/native/layout.rs index c41c95a5..ed357e12 100644 --- a/src/vm/native/layout.rs +++ b/src/vm/native/layout.rs @@ -56,6 +56,101 @@ pub(crate) struct NativeStackLayout { pub(crate) value: ValueLayout, } +pub(crate) fn native_stack_layout_fingerprint(layout: NativeStackLayout) -> u64 { + const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + + let NativeStackLayout { + vm_stack_offset, + vm_locals_offset, + vm_program_constants_ptr_offset, + vm_ip_offset, + vm_fuel_remaining_offset, + vm_fuel_ops_until_check_offset, + vm_epoch_deadline_offset, + vm_epoch_counter_ptr_offset, + vm_jit_native_region_edge_count_offset, + vm_jit_native_direct_link_count_offset, + vm_jit_native_active_direct_trace_id_offset, + stack_vec, + map, + value, + } = layout; + let VecLayout { + ptr_offset: stack_vec_ptr_offset, + len_offset: stack_vec_len_offset, + } = stack_vec; + let MapLayout { + len_offset: map_len_offset, + } = map; + let ValueLayout { + size: value_size, + tag_offset: value_tag_offset, + tag_size: value_tag_size, + null_tag, + int_tag, + float_tag, + bool_tag, + string_tag, + bytes_tag, + array_tag, + map_tag, + int_payload_offset, + float_payload_offset, + bool_payload_offset, + heap_payload_offset, + arc_data_offset, + } = value; + + let mut fingerprint = FNV_OFFSET_BASIS; + for byte in b"pd-vm-native-stack-layout-v1\0" { + fingerprint ^= u64::from(*byte); + fingerprint = fingerprint.wrapping_mul(FNV_PRIME); + } + + macro_rules! hash_field { + ($value:expr) => { + for byte in $value.to_le_bytes() { + fingerprint ^= u64::from(byte); + fingerprint = fingerprint.wrapping_mul(FNV_PRIME); + } + }; + } + + hash_field!(vm_stack_offset); + hash_field!(vm_locals_offset); + hash_field!(vm_program_constants_ptr_offset); + hash_field!(vm_ip_offset); + hash_field!(vm_fuel_remaining_offset); + hash_field!(vm_fuel_ops_until_check_offset); + hash_field!(vm_epoch_deadline_offset); + hash_field!(vm_epoch_counter_ptr_offset); + hash_field!(vm_jit_native_region_edge_count_offset); + hash_field!(vm_jit_native_direct_link_count_offset); + hash_field!(vm_jit_native_active_direct_trace_id_offset); + hash_field!(stack_vec_ptr_offset); + hash_field!(stack_vec_len_offset); + hash_field!(map_len_offset); + hash_field!(value_size); + hash_field!(value_tag_offset); + hash_field!(value_tag_size); + hash_field!(null_tag); + hash_field!(int_tag); + hash_field!(float_tag); + hash_field!(bool_tag); + hash_field!(string_tag); + hash_field!(bytes_tag); + hash_field!(array_tag); + hash_field!(map_tag); + hash_field!(int_payload_offset); + hash_field!(float_payload_offset); + hash_field!(bool_payload_offset); + hash_field!(heap_payload_offset); + hash_field!(arc_data_offset); + + fingerprint +} + pub(crate) fn detect_native_stack_layout() -> VmResult { let cached = NATIVE_STACK_LAYOUT .get_or_init(|| detect_native_stack_layout_uncached().map_err(layout_probe_error_message)); @@ -66,39 +161,43 @@ pub(crate) fn detect_native_stack_layout() -> VmResult { } fn detect_native_stack_layout_uncached() -> VmResult { - let vm_stack_offset = usize_to_i32(std::mem::offset_of!(Vm, stack), "Vm::stack offset")?; - let vm_locals_offset = usize_to_i32(std::mem::offset_of!(Vm, locals), "Vm::locals offset")?; + let vm_stack_offset = + usize_to_i32(std::mem::offset_of!(Vm, instance.stack), "Vm::stack offset")?; + let vm_locals_offset = usize_to_i32( + std::mem::offset_of!(Vm, instance.locals), + "Vm::locals offset", + )?; let vm_program_constants_ptr_offset = usize_to_i32( - std::mem::offset_of!(Vm, program_constants_ptr), + std::mem::offset_of!(Vm, engine.program_constants_ptr), "Vm::program_constants_ptr offset", )?; - let vm_ip_offset = usize_to_i32(std::mem::offset_of!(Vm, ip), "Vm::ip offset")?; + let vm_ip_offset = usize_to_i32(std::mem::offset_of!(Vm, instance.ip), "Vm::ip offset")?; let vm_fuel_remaining_offset = usize_to_i32( - std::mem::offset_of!(Vm, fuel_remaining), + std::mem::offset_of!(Vm, run_ctx.fuel_remaining), "Vm::fuel_remaining offset", )?; let vm_fuel_ops_until_check_offset = usize_to_i32( - std::mem::offset_of!(Vm, fuel_ops_until_check), + std::mem::offset_of!(Vm, run_ctx.fuel_ops_until_check), "Vm::fuel_ops_until_check offset", )?; let vm_epoch_deadline_offset = usize_to_i32( - std::mem::offset_of!(Vm, epoch_deadline), + std::mem::offset_of!(Vm, run_ctx.epoch_deadline), "Vm::epoch_deadline offset", )?; let vm_epoch_counter_ptr_offset = usize_to_i32( - std::mem::offset_of!(Vm, epoch_counter_ptr), + std::mem::offset_of!(Vm, run_ctx.epoch_counter_ptr), "Vm::epoch_counter_ptr offset", )?; let vm_jit_native_region_edge_count_offset = usize_to_i32( - std::mem::offset_of!(Vm, jit_native_region_edge_count), + std::mem::offset_of!(Vm, engine.jit_native_region_edge_count), "Vm::jit_native_region_edge_count offset", )?; let vm_jit_native_direct_link_count_offset = usize_to_i32( - std::mem::offset_of!(Vm, jit_native_direct_link_count), + std::mem::offset_of!(Vm, engine.jit_native_direct_link_count), "Vm::jit_native_direct_link_count offset", )?; let vm_jit_native_active_direct_trace_id_offset = usize_to_i32( - std::mem::offset_of!(Vm, jit_native_active_direct_trace_id), + std::mem::offset_of!(Vm, engine.jit_native_active_direct_trace_id), "Vm::jit_native_active_direct_trace_id offset", )?; let stack_vec = detect_vec_layout()?; @@ -508,3 +607,105 @@ fn usize_to_i32(value: usize, context: &str) -> VmResult { i32::try_from(value) .map_err(|_| VmError::JitNative(format!("{} exceeds 32-bit displacement range", context))) } + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture_layout() -> NativeStackLayout { + NativeStackLayout { + vm_stack_offset: 1, + vm_locals_offset: 2, + vm_program_constants_ptr_offset: 3, + vm_ip_offset: 4, + vm_fuel_remaining_offset: 5, + vm_fuel_ops_until_check_offset: 6, + vm_epoch_deadline_offset: 7, + vm_epoch_counter_ptr_offset: 8, + vm_jit_native_region_edge_count_offset: 9, + vm_jit_native_direct_link_count_offset: 10, + vm_jit_native_active_direct_trace_id_offset: 11, + stack_vec: VecLayout { + ptr_offset: 12, + len_offset: 13, + }, + map: MapLayout { len_offset: 14 }, + value: ValueLayout { + size: 15, + tag_offset: 16, + tag_size: 17, + null_tag: 18, + int_tag: 19, + float_tag: 20, + bool_tag: 21, + string_tag: 22, + bytes_tag: 23, + array_tag: 24, + map_tag: 25, + int_payload_offset: 26, + float_payload_offset: 27, + bool_payload_offset: 28, + heap_payload_offset: 29, + arc_data_offset: 30, + }, + } + } + + #[test] + fn native_stack_layout_fingerprint_has_stable_encoding() { + assert_eq!( + native_stack_layout_fingerprint(fixture_layout()), + 0x5730_e57f_2190_5082 + ); + } + + #[test] + fn native_stack_layout_fingerprint_covers_every_embedded_field() { + let layout = fixture_layout(); + let original = native_stack_layout_fingerprint(layout); + + macro_rules! assert_field_is_covered { + ($($field:ident).+) => {{ + let mut changed = layout; + changed.$($field).+ = changed.$($field).+.wrapping_add(1); + assert_ne!( + native_stack_layout_fingerprint(changed), + original, + "{} must affect the native layout fingerprint", + stringify!($($field).+) + ); + }}; + } + + assert_field_is_covered!(vm_stack_offset); + assert_field_is_covered!(vm_locals_offset); + assert_field_is_covered!(vm_program_constants_ptr_offset); + assert_field_is_covered!(vm_ip_offset); + assert_field_is_covered!(vm_fuel_remaining_offset); + assert_field_is_covered!(vm_fuel_ops_until_check_offset); + assert_field_is_covered!(vm_epoch_deadline_offset); + assert_field_is_covered!(vm_epoch_counter_ptr_offset); + assert_field_is_covered!(vm_jit_native_region_edge_count_offset); + assert_field_is_covered!(vm_jit_native_direct_link_count_offset); + assert_field_is_covered!(vm_jit_native_active_direct_trace_id_offset); + assert_field_is_covered!(stack_vec.ptr_offset); + assert_field_is_covered!(stack_vec.len_offset); + assert_field_is_covered!(map.len_offset); + assert_field_is_covered!(value.size); + assert_field_is_covered!(value.tag_offset); + assert_field_is_covered!(value.tag_size); + assert_field_is_covered!(value.null_tag); + assert_field_is_covered!(value.int_tag); + assert_field_is_covered!(value.float_tag); + assert_field_is_covered!(value.bool_tag); + assert_field_is_covered!(value.string_tag); + assert_field_is_covered!(value.bytes_tag); + assert_field_is_covered!(value.array_tag); + assert_field_is_covered!(value.map_tag); + assert_field_is_covered!(value.int_payload_offset); + assert_field_is_covered!(value.float_payload_offset); + assert_field_is_covered!(value.bool_payload_offset); + assert_field_is_covered!(value.heap_payload_offset); + assert_field_is_covered!(value.arc_data_offset); + } +} diff --git a/src/vm/native/mod.rs b/src/vm/native/mod.rs index 86b536cb..b21b0150 100644 --- a/src/vm/native/mod.rs +++ b/src/vm/native/mod.rs @@ -14,7 +14,8 @@ pub(crate) use bridge::{ aot_call_boundary_interrupt_entry_address, array_push_entry_address, array_set_entry_address, clear_bridge_error, clear_bridge_error_entry_address, clear_value_slot_entry_address, clone_value_to_slot_entry_address, collection_set_entry_address, copy_bytes_entry_address, - decode_jit_trace_exit_status, encode_jit_trace_exit_status, enter_call_value_entry_address, + decode_jit_trace_exit_status, encode_jit_trace_exit_status, enter_call_script_entry_address, + enter_call_script_inherited_entry_address, enter_call_value_entry_address, enter_call_value_inherited_entry_address, frame_state_entry_address, helper_entry_address, helper_entry_offset, init_null_value_slot_entry_address, interrupt_helper_entry_address, interrupt_helper_entry_offset, leave_frame_entry_address, leave_frame_inherited_entry_address, @@ -36,25 +37,31 @@ pub(crate) use bridge::{ pub(crate) use codegen::{ alloc_buffer_signature, array_set_signature, box_heap_value_signature, clone_value_signature, collection_get_signature, collection_mutation_signature, collection_predicate_signature, - copy_bytes_signature, enter_call_value_inherited_signature, enter_call_value_signature, - entry_signature, frame_state_signature, free_buffer_signature, helper_signature, - jump_with_status, leave_frame_inherited_signature, leave_frame_signature, - map_iter_next_signature, map_iter_take_signature, map_set_signature, - non_yielding_host_call_signature, non_yielding_i64_host_call_signature, - non_yielding_scalar_host_call_signature, pack_shared_signature, regex_match_signature, - regex_replace_signature, restore_exit_signature, restore_virtual_frame_signature, - sparse_restore_exit_signature, string_binary_transform_signature, string_contains_signature, - string_replace_signature, string_unary_transform_signature, value_eq_signature, - value_len_signature, value_slot_signature, + copy_bytes_signature, enter_call_script_inherited_signature, enter_call_script_signature, + enter_call_value_inherited_signature, enter_call_value_signature, entry_signature, + frame_state_signature, free_buffer_signature, helper_signature, jump_with_status, + leave_frame_inherited_signature, leave_frame_signature, map_iter_next_signature, + map_iter_take_signature, map_set_signature, non_yielding_host_call_signature, + non_yielding_i64_host_call_signature, non_yielding_scalar_host_call_signature, + pack_shared_signature, regex_match_signature, regex_replace_signature, restore_exit_signature, + restore_virtual_frame_signature, sparse_restore_exit_signature, + string_binary_transform_signature, string_contains_signature, string_replace_signature, + string_unary_transform_signature, value_eq_signature, value_len_signature, + value_slot_signature, }; pub(crate) use exec::{ExecutableBuffer, prepare_for_execution}; pub(crate) use layout::{ NativeStackLayout, ValueLayout, checked_add_i32, detect_native_stack_layout, + native_stack_layout_fingerprint, }; #[cfg(feature = "cranelift-jit")] pub(crate) use offsets::{HeapIntrinsicAddrs, HeapIntrinsicRefs, ResolvedOffsets, resolve_offsets}; -pub(crate) const NATIVE_CALLABLE_ABI_VERSION: u16 = 5; +/// Native callable ABI revision. Bumped for every change to the native +/// callable boundary helpers or their status contract; it is hashed into the +/// program cache identity so stale native products are invalidated exactly +/// once per semantics change. +pub(crate) const NATIVE_CALLABLE_ABI_VERSION: u16 = 6; pub(crate) const MAX_INHERITED_ENTRY_VALUES: usize = 256; pub(crate) const INHERITED_STATE_ACTIVE_OFFSET: i32 = 0; pub(crate) const INHERITED_STATE_FRAME_KEY_OFFSET: i32 = 8; diff --git a/src/vm/program.rs b/src/vm/program.rs new file mode 100644 index 00000000..10d34236 --- /dev/null +++ b/src/vm/program.rs @@ -0,0 +1,22 @@ +//! Immutable program artifact. +//! +//! [`Program`] is the compiled, immutable unit of +//! execution: bytecode, constants, metadata, import requirements, and +//! binding tables. This module documents its ownership contract for the VM +//! runtime decomposition: +//! +//! - A `Program` is immutable after compilation and binding metadata +//! construction; sharing one `Program` (e.g. through `Arc`) is the +//! only supported way to share code between VMs or instances. +//! - Per-run state (stacks, locals, frames, wait state) never lives in the +//! program; it lives in the VM's private `Instance` state. +//! - Backend caches derived from the program (decoded instruction data, +//! operand type hints, AOT/JIT artifacts) live in +//! the VM's private `Engine` state and are keyed by the program's cache +//! identity, never owned by a run. +//! +//! Thread safety: `Program` is `Send + Sync` and `Clone`-cheap only through +//! `Arc`; cloning the struct itself duplicates metadata, which is allowed but +//! wasteful. Prefer `Arc` for sharing. + +pub use crate::bytecode::Program; diff --git a/src/vm/run_context.rs b/src/vm/run_context.rs new file mode 100644 index 00000000..a48495fa --- /dev/null +++ b/src/vm/run_context.rs @@ -0,0 +1,189 @@ +//! Run-scoped execution context. +//! +//! [`RunContext`] owns everything that belongs to one execution of a program: +//! the run-scoped invocation stream configuration (event limits), fuel and +//! epoch budgets, the interrupt mode, and the epoch counter handle. A fresh +//! logical run starts from a reset context; nothing here survives a reset +//! except the epoch handle identity (which is intentionally process-lifetime). +//! +//! The embedder-facing fuel/epoch APIs live on the VM facade (see +//! `crate::vm::fuel` and `crate::vm::epoch`) and delegate here; cancellation +//! of pending host operations lives in the facade because it crosses into +//! [`HostRuntime`](super::host_runtime::HostRuntime) state. + +use crate::builtins::runtime::cancellation::{CancellationReason, CancellationToken}; +use crate::builtins::runtime::context::RuntimeContext; +use crate::vm::VmResult; +use crate::vm::epoch::EpochHandle; + +/// Run interruption mode: no budget, fuel metering, or epoch deadlines. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum InterruptMode { + None = 0, + Fuel = 1, + Epoch = 2, +} + +impl InterruptMode { + pub(crate) fn label(self) -> &'static str { + match self { + Self::None => "none", + Self::Fuel => "fuel", + Self::Epoch => "epoch", + } + } +} + +/// Run-scoped configuration, budgets, deadlines, and interruption state. +/// +/// Thread safety: `RunContext` is `!Sync` (mutable counters) and not shared; +/// one facade owns one context. Clone semantics: not `Clone` — a clone would +/// duplicate run-scoped state across runs. +pub(crate) struct RunContext { + pub(crate) runtime_context: RuntimeContext, + pub(crate) cancellation: CancellationToken, + pub(crate) interrupt_mode: InterruptMode, + pub(crate) fuel_remaining: u64, + pub(crate) fuel_check_interval: u32, + pub(crate) fuel_ops_until_check: u32, + pub(crate) epoch_deadline: u64, + pub(crate) epoch_deadline_delta: u64, + pub(crate) epoch_rearm_pending: bool, + pub(crate) epoch_handle: EpochHandle, + // Native ABI mirror: the epoch counter address read by generated code. + // Load-bearing for `crate::vm::native`; see `crate::vm::engine`. + #[allow(dead_code)] + pub(crate) epoch_counter_ptr: usize, +} + +impl RunContext { + /// Creates a fresh run context with default event limits and no budgets + /// (interrupts disabled). + pub(crate) fn new() -> Self { + let epoch_handle = EpochHandle::default(); + let epoch_counter_ptr = epoch_handle.as_ptr() as usize; + Self { + runtime_context: RuntimeContext::default(), + cancellation: CancellationToken::root(), + interrupt_mode: InterruptMode::None, + fuel_remaining: 0, + fuel_check_interval: 1, + fuel_ops_until_check: 1, + epoch_deadline: 0, + epoch_deadline_delta: 0, + epoch_rearm_pending: false, + epoch_handle, + epoch_counter_ptr, + } + } + + /// Closes run-scoped state for reuse: fuel/epoch budgets are dropped + /// (metering disabled, no leftovers). The invocation stream event limits + /// are configuration and intentionally survive a reset. + pub(crate) fn reset_for_reuse(&mut self) { + self.cancellation.cancel(CancellationReason::VmReset); + self.cancellation = CancellationToken::root(); + self.epoch_rearm_pending = false; + self.clear_fuel_internal(); + self.clear_epoch_deadline_internal(); + } + + pub(crate) fn cancel(&self, reason: CancellationReason) -> VmResult<()> { + self.cancellation.cancel(reason); + match self.cancellation.take_propagation_error() { + Some(error) => Err(crate::vm::VmError::HostError(error.to_string())), + None => Ok(()), + } + } + + pub(crate) fn reset_interrupt_countdown(&mut self) { + self.fuel_ops_until_check = self.fuel_check_interval.max(1); + } + + pub(crate) fn clear_fuel_internal(&mut self) { + if self.interrupt_mode == InterruptMode::Fuel { + self.interrupt_mode = InterruptMode::None; + } + self.fuel_remaining = 0; + self.reset_interrupt_countdown(); + } + + pub(crate) fn clear_epoch_deadline_internal(&mut self) { + if self.interrupt_mode == InterruptMode::Epoch { + self.interrupt_mode = InterruptMode::None; + } + self.epoch_deadline = 0; + self.epoch_deadline_delta = 0; + self.epoch_rearm_pending = false; + self.reset_interrupt_countdown(); + } + + pub(crate) fn pending_fuel_debt(&self) -> u64 { + if self.interrupt_mode != InterruptMode::Fuel { + return 0; + } + let executed_since_last_check = self + .fuel_check_interval + .saturating_sub(self.fuel_ops_until_check); + u64::from(executed_since_last_check) + } + + /// Charges a fixed amount of fuel; errors when the budget is exhausted. + pub(crate) fn charge_fuel(&mut self, amount: u64) -> VmResult<()> { + if amount == 0 || self.interrupt_mode != InterruptMode::Fuel { + return Ok(()); + } + let remaining = self.fuel_remaining; + if remaining < amount { + return Err(crate::vm::VmError::OutOfFuel { + needed: amount, + remaining, + }); + } + self.fuel_remaining = remaining - amount; + Ok(()) + } + + /// Charges one fuel interval according to the countdown; errors when the + /// budget is exhausted. + pub(crate) fn charge_fuel_tick(&mut self) -> VmResult<()> { + if self.interrupt_mode != InterruptMode::Fuel { + return Ok(()); + } + if self.fuel_ops_until_check > 1 { + self.fuel_ops_until_check -= 1; + return Ok(()); + } + let amount = u64::from(self.fuel_check_interval); + self.charge_fuel(amount)?; + self.fuel_ops_until_check = self.fuel_check_interval; + Ok(()) + } + + /// Charges one epoch countdown tick; errors when the deadline passed. + pub(crate) fn charge_epoch_tick(&mut self) -> VmResult<()> { + if self.interrupt_mode != InterruptMode::Epoch { + return Ok(()); + } + if self.fuel_ops_until_check > 1 { + self.fuel_ops_until_check -= 1; + return Ok(()); + } + let current = self.epoch_handle.current(); + if current >= self.epoch_deadline { + return Err(crate::vm::VmError::EpochDeadlineReached { + current, + deadline: self.epoch_deadline, + }); + } + self.fuel_ops_until_check = self.fuel_check_interval; + Ok(()) + } +} + +impl Default for RunContext { + fn default() -> Self { + Self::new() + } +} diff --git a/src/vm/superinstructions.rs b/src/vm/superinstructions.rs index 47c8e581..e6e87f1f 100644 --- a/src/vm/superinstructions.rs +++ b/src/vm/superinstructions.rs @@ -46,7 +46,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_ldc_value_at(&self, opcode_ip: usize) -> Option<&Value> { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .ldc_values .get(opcode_ip) .and_then(|value| value.as_ref()) @@ -54,7 +55,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_jump_target_at(&self, opcode_ip: usize) -> Option { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .jump_targets .get(opcode_ip) .and_then(|target| *target) @@ -62,7 +64,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_jump_target_is_valid_at(&self, opcode_ip: usize) -> bool { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .valid_jump_targets .get(opcode_ip) .copied() @@ -71,7 +74,8 @@ impl Vm { #[inline(always)] pub(super) fn decoded_local_index_at(&self, opcode_ip: usize) -> Option { - self.decoded_instruction_data + self.engine + .decoded_instruction_data .local_indices .get(opcode_ip) .and_then(|index| *index) @@ -90,7 +94,7 @@ impl Vm { let Some(initial) = self.local_scalar_value_with_hint(src) else { return Ok(false); }; - let mut cursor = self.ip; + let mut cursor = self.instance.ip; let mut stack = [None; 8]; let mut stack_len = 1usize; stack[0] = Some(initial); @@ -221,7 +225,7 @@ impl Vm { ))?; self.store_local_absolute_with_drop_contract(absolute, dst, value)?; self.record_scalar_superinstruction(); - self.ip = cursor + 2; + self.instance.ip = cursor + 2; return Ok(true); } OpCode::Clt | OpCode::Cgt => { @@ -261,10 +265,10 @@ impl Vm { }, _ => unreachable!(), }; - self.ip = cursor + 6; + self.instance.ip = cursor + 6; if !condition { if self.decoded_jump_target_is_valid_at(jump_opcode_ip) { - self.ip = target; + self.instance.ip = target; } else { self.jump_to(target)?; } diff --git a/src/vm/tests.rs b/src/vm/tests.rs index b7f0e0d1..94690272 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -1,7 +1,11 @@ +use super::async_host::WaitingHostOp; use super::*; use crate::builtins::BuiltinFunction; use crate::bytecode::TypeMap; +#[cfg(feature = "sqlite")] +use crate::{SqliteHostExt, SqlitePolicy}; use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; fn native_cache_test_lock() -> &'static Mutex<()> { @@ -9,18 +13,67 @@ fn native_cache_test_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } +#[test] +fn failed_dynamic_builtin_override_preserves_runtime_owned_pending_binding() { + struct Dummy; + + impl HostFunction for Dummy { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + unreachable!("rejected override must never be installed") + } + } + + let compiled = crate::compile_source("use runtime; runtime::sleep(0);") + .expect("runtime sleep program should compile"); + let mut vm = Vm::new(compiled.program); + vm.ensure_call_bindings() + .expect("default fallback should bind runtime sleep"); + let slot = vm.host.host_function_symbols["runtime::sleep"]; + vm.host.runtime_owned_pending_host_slots.insert(slot); + assert!(vm.host.runtime_owned_pending_host_slots.contains(&slot)); + + vm.bind_builtin_override("runtime::sleep", Box::new(Dummy)) + .expect_err("runtime sleep is a host import, not a builtin override"); + + assert!(vm.host.runtime_owned_pending_host_slots.contains(&slot)); +} + +#[test] +fn failed_static_builtin_override_preserves_runtime_owned_pending_binding() { + fn dummy(_vm: &mut Vm, _args: &[Value]) -> VmResult { + unreachable!("rejected override must never be installed") + } + + let compiled = crate::compile_source("use runtime; runtime::sleep(0);") + .expect("runtime sleep program should compile"); + let mut vm = Vm::new(compiled.program); + vm.ensure_call_bindings() + .expect("default fallback should bind runtime sleep"); + let slot = vm.host.host_function_symbols["runtime::sleep"]; + vm.host.runtime_owned_pending_host_slots.insert(slot); + assert!(vm.host.runtime_owned_pending_host_slots.contains(&slot)); + + vm.bind_builtin_static_override("runtime::sleep", dummy) + .expect_err("runtime sleep is a host import, not a builtin override"); + + assert!(vm.host.runtime_owned_pending_host_slots.contains(&slot)); +} + #[test] fn root_ret_completes_explicit_halt_frame() { let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); - assert_eq!(vm.execution_frames.len(), 1); - assert_eq!(vm.execution_frames[0].continuation, FrameContinuation::Halt); + assert_eq!(vm.instance.execution_frames.len(), 1); + assert_eq!( + vm.instance.execution_frames[0].continuation, + FrameContinuation::Halt + ); assert_eq!(vm.run().expect("root ret should run"), VmStatus::Halted); - assert!(vm.execution_frames.is_empty()); + assert!(vm.instance.execution_frames.is_empty()); assert!(vm.stack().is_empty()); vm.reset_for_reuse(); - assert_eq!(vm.execution_frames.len(), 1); + assert_eq!(vm.instance.execution_frames.len(), 1); assert_eq!(vm.stack(), &[]); } @@ -32,11 +85,521 @@ fn reset_for_reuse_keeps_host_operation_ids_monotonic() { assert_eq!(vm.allocate_host_op_id(), 2); } +#[test] +fn async_host_future_is_submitted_to_the_host_bridge() { + use std::sync::{Arc, Mutex}; + + struct RecordingBridge { + submitted: Arc>>, + future: Arc>>, + } + + impl HostAsyncBridge for RecordingBridge { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + self.submitted.lock().expect("submitted lock").push(op_id); + *self.future.lock().expect("future lock") = Some(future); + Ok(()) + } + + fn poll_op( + &mut self, + _op_id: HostOpId, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + } + + let submitted = Arc::new(Mutex::new(Vec::new())); + let future = Arc::new(Mutex::new(None)); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(RecordingBridge { + submitted: Arc::clone(&submitted), + future: Arc::clone(&future), + })); + + let outcome = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::one(Value::Int(42)))) + })) + .expect("host bridge should accept future"); + let CallOutcome::Pending(op_id) = outcome else { + panic!("async host submission should suspend"); + }; + + assert_eq!(*submitted.lock().expect("submitted lock"), vec![op_id]); + assert!(future.lock().expect("future lock").is_some()); + assert_eq!(vm.host.runtime_operations.active_count(), 0); +} + +#[test] +fn async_host_submission_without_driver_fails_and_retires_the_id() { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let error = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::none())) + })) + .expect_err("missing host async driver should fail"); + + assert!( + error + .to_string() + .contains("async host function requires a host async bridge") + ); + assert_eq!(vm.allocate_host_op_id(), 2); + assert_eq!(vm.host.runtime_operations.active_count(), 0); +} + +#[test] +fn completing_a_submitted_host_op_cancels_the_driver_future() { + use std::sync::{Arc, Mutex}; + + struct CancelRecordingBridge(Arc>>); + + impl HostAsyncBridge for CancelRecordingBridge { + fn submit_op(&mut self, _op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + Ok(()) + } + + fn poll_op( + &mut self, + _op_id: HostOpId, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + + fn cancel_op(&mut self, op_id: HostOpId) { + self.0.lock().expect("cancel lock").push(op_id); + } + } + + let cancelled = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(CancelRecordingBridge(Arc::clone(&cancelled)))); + let CallOutcome::Pending(op_id) = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::none())) + })) + .expect("future should submit") + else { + panic!("submission should return pending"); + }; + vm.set_waiting_host_op(op_id) + .expect("submitted op should register"); + + vm.complete_host_op(op_id, CallReturn::none()) + .expect("manual completion should succeed"); + + assert_eq!(*cancelled.lock().expect("cancel lock"), vec![op_id]); + assert_eq!(vm.waiting_host_op_id(), None); + assert_eq!(vm.host.runtime_operations.active_count(), 0); +} + +#[test] +fn failed_submitted_host_completion_clears_waiting_state() { + struct FailingCompletionBridge; + + impl HostAsyncBridge for FailingCompletionBridge { + fn submit_op(&mut self, _op_id: HostOpId, _future: HostFuture) -> VmResult<()> { + Ok(()) + } + + fn poll_op( + &mut self, + _op_id: HostOpId, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + + fn poll_submitted_op( + &mut self, + _op_id: HostOpId, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(HostFutureOutput::complete(|_| { + Err(VmError::HostError("completion failed".to_string())) + }))) + } + } + + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_async_bridge(Box::new(FailingCompletionBridge)); + let CallOutcome::Pending(op_id) = vm + .submit_host_future(Box::pin(async { + Ok(HostFutureOutput::returning(CallReturn::none())) + })) + .expect("future should submit") + else { + panic!("submission should return pending"); + }; + vm.set_waiting_host_op(op_id) + .expect("submitted op should register"); + let waker = futures_util::task::noop_waker(); + let mut context = std::task::Context::from_waker(&waker); + + let result = vm.poll_waiting_host_op(&mut context); + + assert!(matches!( + result, + std::task::Poll::Ready(Err(VmError::HostError(message))) + if message == "completion failed" + )); + assert_eq!(vm.waiting_host_op_id(), None); + assert_eq!(vm.host.runtime_operations.active_count(), 0); +} + +#[test] +fn unused_host_operation_ids_do_not_consume_registry_capacity() { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + for _ in 0..128 { + vm.allocate_host_op_id(); + } + assert_eq!(vm.host.runtime_operations.active_count(), 0); +} + +#[test] +fn external_host_operations_join_the_shared_registry_without_id_collisions() { + use crate::builtins::runtime::cancellation::{OperationId, OperationOwner}; + + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let runtime_operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Io, + Some(&vm.run_ctx.cancellation), + None, + None, + ) + .expect("runtime operation should start"); + + let collision = vm + .set_waiting_host_op(runtime_operation.id().raw()) + .expect_err("host operation must not reuse a runtime-owned id"); + assert!(collision.to_string().contains("collides")); + assert!( + vm.host + .runtime_operations + .get(runtime_operation.id()) + .is_ok() + ); + vm.host + .runtime_operations + .complete(runtime_operation.id()) + .expect("runtime operation should complete"); + vm.set_waiting_host_op(runtime_operation.id().raw()) + .expect_err("colliding external operation id must remain retired"); + + vm.set_waiting_host_op(99) + .expect("external host operation should register"); + let external = vm + .host + .runtime_operations + .get(OperationId::from_raw(99).expect("operation id should be valid")) + .expect("external operation should be registered"); + assert_eq!(external.owner(), OperationOwner::HostBridge); +} + +#[test] +fn invalid_host_completion_preserves_the_registered_operation() { + use crate::builtins::runtime::cancellation::{OperationId, OperationOwner}; + + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + vm.set_waiting_host_op(101) + .expect("external host operation should register"); + + vm.complete_host_op(102, CallReturn::none()) + .expect_err("completion for a different operation should fail"); + let operation_id = OperationId::from_raw(101).expect("operation id should be valid"); + assert_eq!( + vm.host + .runtime_operations + .get(operation_id) + .expect("waiting operation should remain registered") + .owner(), + OperationOwner::HostBridge + ); + assert_eq!(vm.waiting_host_op_id(), Some(101)); +} + +#[test] +fn reset_and_drop_cleanup_real_host_resources_exactly_once() { + use crate::builtins::runtime::cancellation::CancellationReason; + use crate::builtins::runtime::resource::ResourceTypeId; + + let cleanup_count = Arc::new(AtomicUsize::new(0)); + let reasons = Arc::new(Mutex::new(Vec::new())); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let cleanup_count_for_resource = Arc::clone(&cleanup_count); + let reasons_for_resource = Arc::clone(&reasons); + vm.host + .runtime_resources + .insert_with_cleanup(ResourceTypeId::IO_FILE, (), move |(), reason| { + cleanup_count_for_resource.fetch_add(1, Ordering::SeqCst); + reasons_for_resource + .lock() + .expect("reason lock") + .push(reason); + Ok(()) + }) + .expect("test resource should be inserted"); + + vm.reset_for_reuse(); + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); + assert_eq!( + reasons.lock().expect("reason lock").as_slice(), + &[CancellationReason::VmReset] + ); + + drop(vm); + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); +} + +#[test] +fn drop_cleans_real_host_resources_without_prior_reset() { + use crate::builtins::runtime::cancellation::CancellationReason; + use crate::builtins::runtime::resource::ResourceTypeId; + + let cleanup_count = Arc::new(AtomicUsize::new(0)); + let cleanup_reason = Arc::new(Mutex::new(None)); + { + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let cleanup_count_for_resource = Arc::clone(&cleanup_count); + let cleanup_reason_for_resource = Arc::clone(&cleanup_reason); + vm.host + .runtime_resources + .insert_with_cleanup(ResourceTypeId::IO_FILE, (), move |(), reason| { + cleanup_count_for_resource.fetch_add(1, Ordering::SeqCst); + *cleanup_reason_for_resource.lock().expect("reason lock") = Some(reason); + Ok(()) + }) + .expect("test resource should be inserted"); + } + + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); + assert_eq!( + *cleanup_reason.lock().expect("reason lock"), + Some(CancellationReason::VmReset) + ); +} + +#[test] +fn reset_propagates_to_real_host_operation_cleanup() { + use crate::builtins::runtime::cancellation::{ + CancellationReason, OperationEnd, OperationOwner, OperationStatus, + }; + + let cleanup_end = Arc::new(Mutex::new(None)); + let cleanup_end_for_operation = Arc::clone(&cleanup_end); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Io, + Some(&vm.run_ctx.cancellation), + None, + Some(Box::new(move |end| { + *cleanup_end_for_operation.lock().expect("cleanup lock") = Some(end); + Ok(()) + })), + ) + .expect("test operation should start"); + vm.instance.waiting_host_op = Some(WaitingHostOp { + op_id: operation.id().raw(), + }); + + vm.reset_for_reuse(); + assert_eq!( + operation.status(), + OperationStatus::Cancelled(CancellationReason::VmReset) + ); + assert_eq!( + *cleanup_end.lock().expect("cleanup lock"), + Some(OperationEnd::Cancelled(CancellationReason::VmReset)) + ); +} + +#[test] +fn deadline_cancellation_closes_operation_payload_before_registry_removal() { + use crate::builtins::runtime::cancellation::{CancellationReason, OperationOwner}; + use crate::builtins::runtime::resource::ResourceTypeId; + use std::task::{Context, Poll}; + use std::time::{Duration, Instant}; + + let cleanup_reason = Arc::new(Mutex::new(None)); + let cleanup_reason_for_payload = Arc::clone(&cleanup_reason); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Http, + Some(&vm.run_ctx.cancellation), + Some(Instant::now() - Duration::from_millis(1)), + None, + ) + .expect("deadline operation should start"); + let payload = vm + .host + .runtime_resources + .insert_with_cleanup(ResourceTypeId::CALLBACK, (), move |(), reason| { + *cleanup_reason_for_payload.lock().expect("cleanup lock") = Some(reason); + Ok(()) + }) + .expect("payload should be inserted"); + operation.set_payload(payload); + + let waker = futures_util::task::noop_waker(); + let mut context = Context::from_waker(&waker); + let result = + crate::builtins::runtime::poll_builtin_io_op(&mut vm, operation.id().raw(), &mut context); + + assert!(matches!(result, Poll::Ready(Err(_)))); + assert_eq!( + *cleanup_reason.lock().expect("cleanup lock"), + Some(CancellationReason::Deadline) + ); + assert!(vm.host.runtime_operations.get(operation.id()).is_err()); + assert!( + vm.host + .runtime_resources + .get::<()>(payload, ResourceTypeId::CALLBACK) + .is_err() + ); +} + +#[test] +fn worker_observed_deadline_retains_payload_until_vm_consumes_operation() { + use crate::builtins::runtime::cancellation::{CancellationReason, OperationOwner}; + use crate::builtins::runtime::error::{RuntimeError, RuntimeErrorCode}; + use crate::builtins::runtime::resource::ResourceTypeId; + use std::task::{Context, Poll}; + use std::time::{Duration, Instant}; + + let cleanup_reason = Arc::new(Mutex::new(None)); + let cleanup_reason_for_payload = Arc::clone(&cleanup_reason); + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Http, + Some(&vm.run_ctx.cancellation), + Some(Instant::now() - Duration::from_millis(1)), + None, + ) + .expect("deadline operation should start"); + let payload = vm + .host + .runtime_resources + .insert_with_cleanup(ResourceTypeId::CALLBACK, (), move |(), reason| { + *cleanup_reason_for_payload.lock().expect("cleanup lock") = Some(reason); + Ok(()) + }) + .expect("payload should be inserted"); + operation.set_payload(payload); + + assert!( + operation + .fail(RuntimeError::new( + RuntimeErrorCode::OperationFailed, + "test::worker", + "worker failure", + )) + .expect("worker terminal transition should succeed") + ); + assert!(vm.host.runtime_operations.get(operation.id()).is_ok()); + + let waker = futures_util::task::noop_waker(); + let mut context = Context::from_waker(&waker); + let result = + crate::builtins::runtime::poll_builtin_io_op(&mut vm, operation.id().raw(), &mut context); + + assert!(matches!(result, Poll::Ready(Err(_)))); + assert_eq!( + *cleanup_reason.lock().expect("cleanup lock"), + Some(CancellationReason::Deadline) + ); + assert!(vm.host.runtime_operations.get(operation.id()).is_err()); + assert!( + vm.host + .runtime_resources + .get::<()>(payload, ResourceTypeId::CALLBACK) + .is_err() + ); +} + +#[cfg(feature = "sqlite")] +#[test] +fn sqlite_reconfiguration_only_closes_sqlite_owned_state() { + use crate::builtins::runtime::cancellation::OperationOwner; + use crate::builtins::runtime::resource::ResourceTypeId; + + let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])); + let io_resource = vm + .host + .runtime_resources + .insert(ResourceTypeId::IO_FILE, 11_i64) + .expect("IO resource should be inserted"); + let sqlite_resource = vm + .host + .runtime_resources + .insert(ResourceTypeId::SQLITE_CONNECTION, 22_i64) + .expect("SQLite resource should be inserted"); + let io_operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Io, + Some(&vm.run_ctx.cancellation), + None, + None, + ) + .expect("IO operation should start"); + io_operation.set_resource(io_resource); + let sqlite_operation = vm + .host + .runtime_operations + .start_owned( + OperationOwner::Sqlite, + Some(&vm.run_ctx.cancellation), + None, + None, + ) + .expect("SQLite operation should start"); + sqlite_operation.set_resource(sqlite_resource); + + vm.configure_sqlite(SqlitePolicy::default()); + + assert!( + vm.host + .runtime_resources + .get::(io_resource, ResourceTypeId::IO_FILE) + .is_ok() + ); + assert!(vm.host.runtime_operations.get(io_operation.id()).is_ok()); + assert!( + vm.host + .runtime_resources + .get::(sqlite_resource, ResourceTypeId::SQLITE_CONNECTION) + .is_err() + ); + assert!( + vm.host + .runtime_operations + .get(sqlite_operation.id()) + .is_err() + ); +} + #[test] fn shared_capture_cell_rejects_callable_ownership_cycle() { let mut vm = Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8]).with_local_count(1)); let cell = Arc::new(Mutex::new(Value::Null)); - vm.capture_cells.insert(0, Arc::clone(&cell)); + vm.instance.capture_cells.insert(0, Arc::clone(&cell)); let environment = Arc::new(crate::CallableEnvironment { cells: Mutex::new(vec![cell]), }); @@ -109,7 +672,7 @@ fn callvalue_decodes_its_arity_before_callable_validation() { Vec::new(), vec![OpCode::CallValue as u8, 0, OpCode::Ret as u8], )); - vm.stack.push(Value::Null); + vm.instance.stack.push(Value::Null); assert!(matches!(vm.run(), Err(VmError::InvalidCallable))); assert_eq!(vm.ip(), 2); } @@ -293,7 +856,7 @@ fn aot_executes_move_detach_without_stack_contract_mismatch() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::String(Arc::new("x".to_string()))]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -302,6 +865,7 @@ fn aot_executes_script_callable_frames_without_interpreter_boundary() { let compiled = crate::compile_source_for_repl( r#" fn add_one(value: int) -> int { value + 1 } + let f = add_one; add_one(41); "#, ) @@ -314,7 +878,7 @@ fn aot_executes_script_callable_frames_without_interpreter_boundary() { ); assert_eq!(vm.stack(), &[Value::Int(42)]); assert!(vm.aot_exec_count() >= 3); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -323,6 +887,7 @@ fn aot_executes_typed_script_callable_parameter_equality_without_interpreter_bou let compiled = crate::compile_source( r#" fn is_zero(value: int) -> bool { value == 0 } + let f = is_zero; is_zero(0); "#, ) @@ -334,7 +899,7 @@ fn aot_executes_typed_script_callable_parameter_equality_without_interpreter_bou VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Bool(true)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -343,6 +908,7 @@ fn aot_executes_script_callable_bool_return_in_branch_without_interpreter_bounda let compiled = crate::compile_source( r#" fn is_zero(value: int) -> bool { value == 0 } + let f = is_zero; let selected = if is_zero(0) => { 1 } else => { 2 }; selected; "#, @@ -355,7 +921,7 @@ fn aot_executes_script_callable_bool_return_in_branch_without_interpreter_bounda VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(1)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -377,7 +943,7 @@ fn aot_executes_capturing_closure_without_interpreter_boundary() { ); assert_eq!(vm.stack(), &[Value::Int(42)]); assert!(vm.aot_exec_count() >= 3); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -397,7 +963,7 @@ fn aot_executes_builtin_callable_values_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(3)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -406,6 +972,7 @@ fn aot_callable_call_resumes_after_fuel_yield_without_interpreter_boundary() { let compiled = crate::compile_source_for_repl( r#" fn add_one(value: int) -> int { value + 1 } + let f = add_one; add_one(41); "#, ) @@ -424,7 +991,7 @@ fn aot_callable_call_resumes_after_fuel_yield_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(42)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -434,6 +1001,8 @@ fn aot_executes_nested_script_callables_without_interpreter_boundary() { r#" fn inc(value: int) -> int { value + 1 } fn twice(value: int) -> int { inc(inc(value)) } + let f = inc; + let g = twice; twice(40); "#, ) @@ -445,7 +1014,7 @@ fn aot_executes_nested_script_callables_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(42)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -454,6 +1023,7 @@ fn aot_recursive_script_callable_reports_depth_limit_without_interpreter_boundar let compiled = crate::compile_source_for_repl( r#" fn recurse(value: int) -> int { recurse(value) } + let f = recurse; recurse(1); "#, ) @@ -464,7 +1034,7 @@ fn aot_recursive_script_callable_reports_depth_limit_without_interpreter_boundar vm.run(), Err(VmError::CallStackOverflow { limit: 1024 }) )); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[cfg(feature = "cranelift-jit")] @@ -493,7 +1063,7 @@ fn aot_host_callable_value_waits_and_resumes_without_interpreter_boundary() { vm.run().expect("pending host callable should wait"), VmStatus::Waiting(812) ); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); vm.complete_host_op(812, vec![Value::Int(42)]) .expect("host operation should complete"); assert_eq!( @@ -501,7 +1071,7 @@ fn aot_host_callable_value_waits_and_resumes_without_interpreter_boundary() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(42)]); - assert!(!vm.aot_interpreter_boundary_hit); + assert!(!vm.engine.aot_interpreter_boundary_hit); } #[test] @@ -943,8 +1513,8 @@ fn vm_instances_share_decoded_instruction_metadata_across_program_clones() { assert!( Arc::ptr_eq( - &vm_one.decoded_instruction_data, - &vm_two.decoded_instruction_data + &vm_one.engine.decoded_instruction_data, + &vm_two.engine.decoded_instruction_data ), "program clones should share decoded instruction metadata" ); @@ -969,7 +1539,11 @@ fn borrowed_map_iterator_state_is_released_after_break() { assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); assert!( - vm.map_iterators.iter().flatten().all(Option::is_none), + vm.instance + .map_iterators + .iter() + .flatten() + .all(Option::is_none), "break must release every iterator owned by the exited loop" ); } @@ -991,7 +1565,11 @@ fn borrowed_map_iterator_state_is_released_after_runtime_error() { vm.run().expect_err("program should fail at runtime"); assert!( - vm.map_iterators.iter().flatten().all(Option::is_none), + vm.instance + .map_iterators + .iter() + .flatten() + .all(Option::is_none), "runtime errors must release active map iterators" ); } @@ -1008,7 +1586,7 @@ fn map_iterator_ids_are_isolated_by_call_depth() { }; vm.init_map_iterator(7, outer).expect("outer init"); - vm.call_depth = 1; + vm.instance.call_depth = 1; vm.init_map_iterator(7, inner).expect("inner init"); assert!(vm.advance_map_iterator(7).expect("inner advance")); assert_eq!( @@ -1017,7 +1595,7 @@ fn map_iterator_ids_are_isolated_by_call_depth() { ); vm.close_map_iterator(7).expect("inner close"); - vm.call_depth = 0; + vm.instance.call_depth = 0; assert!(vm.advance_map_iterator(7).expect("outer advance")); assert_eq!( vm.take_map_iterator_key(7).expect("outer key"), @@ -1079,7 +1657,7 @@ fn native_trace_cache_resets_when_program_changes() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_one, - Some(vm_one.program_cache_key), + Some(vm_one.engine.program_cache_key), "cache should be keyed to first program after first run" ); assert_eq!( @@ -1094,7 +1672,7 @@ fn native_trace_cache_resets_when_program_changes() { max_trace_len: 512, }); assert_ne!( - vm_one.program_cache_key, vm_two.program_cache_key, + vm_one.engine.program_cache_key, vm_two.engine.program_cache_key, "test programs should have different cache keys" ); let status_two = vm_two.run().expect("second vm should run"); @@ -1109,7 +1687,7 @@ fn native_trace_cache_resets_when_program_changes() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_two, - Some(vm_two.program_cache_key), + Some(vm_two.engine.program_cache_key), "cache should switch to second program key" ); assert_eq!( @@ -1161,7 +1739,7 @@ fn native_trace_cache_reuses_entries_for_same_program() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_one, - Some(vm_one.program_cache_key), + Some(vm_one.engine.program_cache_key), "cache should be keyed to the first program" ); assert_eq!( @@ -1176,7 +1754,7 @@ fn native_trace_cache_reuses_entries_for_same_program() { max_trace_len: 512, }); assert_eq!( - vm_two.program_cache_key, vm_one.program_cache_key, + vm_two.engine.program_cache_key, vm_one.engine.program_cache_key, "same program should use identical cache key" ); @@ -1192,7 +1770,7 @@ fn native_trace_cache_reuses_entries_for_same_program() { jit::runtime::native_trace_cache_snapshot_for_tests(); assert_eq!( cache_program_after_two, - Some(vm_two.program_cache_key), + Some(vm_two.engine.program_cache_key), "cache key should remain the same for identical program" ); assert_eq!( @@ -1344,7 +1922,7 @@ fn interpreter_superinstructions_use_local_type_hints() { let outcome = step_once(&mut vm).expect("ldloc should fuse scalar sequence"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.locals[0], Value::Int(10)); + assert_eq!(vm.instance.locals[0], Value::Int(10)); let metrics = vm.interpreter_metrics_snapshot(); assert_eq!(metrics.scalar_superinstruction_count, 1); assert!( @@ -1375,7 +1953,8 @@ fn interpreter_ldc_shares_string_constant_backing() { fn interpreter_dup_shares_array_backing() { let program = Program::new(vec![], vec![OpCode::Dup as u8, OpCode::Ret as u8]); let mut vm = Vm::new(program); - vm.stack + vm.instance + .stack .push(Value::array(vec![Value::Int(1), Value::Int(2)])); let outcome = step_once(&mut vm).expect("dup should execute"); @@ -1503,14 +2082,17 @@ fn interpreter_ldloc_preserves_local_slot() { let outcome = step_once(&mut vm).expect("ldloc should execute"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 2); - assert_eq!(vm.locals[0], map_value, "ldloc should leave local intact"); + assert_eq!(vm.instance.ip, 2); + assert_eq!( + vm.instance.locals[0], map_value, + "ldloc should leave local intact" + ); assert_eq!( vm.stack(), &[map_value], "stack should receive copied value" ); - assert_shared_heap_backing(&vm.locals[0], &vm.stack()[0]); + assert_shared_heap_backing(&vm.instance.locals[0], &vm.stack()[0]); assert_eq!(vm.drop_contract_event_count(), 0); } @@ -1539,9 +2121,9 @@ fn interpreter_explicit_move_sequence_clears_local_slot() { let ldloc = step_once(&mut vm).expect("ldloc should execute"); assert!(matches!(ldloc, ExecOutcome::Continue)); - assert_eq!(vm.locals[0], map_value); + assert_eq!(vm.instance.locals[0], map_value); assert_eq!(vm.stack(), std::slice::from_ref(&map_value)); - assert_shared_heap_backing(&vm.locals[0], &vm.stack()[0]); + assert_shared_heap_backing(&vm.instance.locals[0], &vm.stack()[0]); let ldc = step_once(&mut vm).expect("ldc should execute"); assert!(matches!(ldc, ExecOutcome::Continue)); @@ -1549,8 +2131,8 @@ fn interpreter_explicit_move_sequence_clears_local_slot() { let stloc = step_once(&mut vm).expect("stloc should execute"); assert!(matches!(stloc, ExecOutcome::Continue)); - assert_eq!(vm.ip, 9); - assert_eq!(vm.locals[0], Value::Null); + assert_eq!(vm.instance.ip, 9); + assert_eq!(vm.instance.locals[0], Value::Null); assert_eq!(vm.stack(), &[map_value]); } @@ -1579,9 +2161,9 @@ fn interpreter_fuses_ldloc_ldc_add_stloc_without_touching_stack() { let outcome = step_once(&mut vm).expect("fused sequence should execute"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 10, "fusion should consume ldc/add/stloc"); - assert_eq!(vm.locals[0], Value::Int(41)); - assert_eq!(vm.locals[1], Value::Int(42)); + assert_eq!(vm.instance.ip, 10, "fusion should consume ldc/add/stloc"); + assert_eq!(vm.instance.locals[0], Value::Int(41)); + assert_eq!(vm.instance.locals[1], Value::Int(42)); assert!( vm.stack().is_empty(), "fusion should avoid transient stack traffic" @@ -1621,7 +2203,10 @@ fn interpreter_fuses_ldloc_ldc_compare_brfalse() { let outcome = step_once(&mut vm).expect("fused compare should execute"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 15, "fusion should jump directly to branch target"); + assert_eq!( + vm.instance.ip, 15, + "fusion should jump directly to branch target" + ); assert!( vm.stack().is_empty(), "fusion should avoid bool stack traffic" @@ -1664,9 +2249,9 @@ fn interpreter_fuses_generic_scalar_update_chain() { let outcome = step_once(&mut vm).expect("generic chain should fuse"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 19); - assert_eq!(vm.locals[0], Value::Int(29)); - assert_eq!(vm.locals[1], Value::Int(4)); + assert_eq!(vm.instance.ip, 19); + assert_eq!(vm.instance.locals[0], Value::Int(29)); + assert_eq!(vm.instance.locals[1], Value::Int(4)); assert!(vm.stack().is_empty()); } @@ -1708,13 +2293,13 @@ fn interpreter_fuses_float_scalar_sequences() { let first = step_once(&mut vm).expect("float update should fuse"); assert!(matches!(first, ExecOutcome::Continue)); - assert_eq!(vm.ip, 10); - assert_eq!(vm.locals[0], Value::Float(2.5)); + assert_eq!(vm.instance.ip, 10); + assert_eq!(vm.instance.locals[0], Value::Float(2.5)); assert!(vm.stack().is_empty()); let second = step_once(&mut vm).expect("float compare should fuse"); assert!(matches!(second, ExecOutcome::Continue)); - assert_eq!(vm.ip, 23); + assert_eq!(vm.instance.ip, 23); assert!(vm.stack().is_empty()); } @@ -1747,9 +2332,12 @@ fn interpreter_does_not_fuse_ldloc_sequences_when_fuel_is_enabled() { .execute_interpreter_instruction(opcode, false) .expect("ldloc should execute without fusion"); assert!(matches!(outcome, ExecOutcome::Continue)); - assert_eq!(vm.ip, 2, "ldloc should advance only past its operand"); + assert_eq!( + vm.instance.ip, 2, + "ldloc should advance only past its operand" + ); assert_eq!(vm.stack(), &[Value::Int(41)]); - assert_eq!(vm.locals[0], Value::Int(41)); + assert_eq!(vm.instance.locals[0], Value::Int(41)); } #[test] @@ -1776,7 +2364,7 @@ fn interpreter_copy_like_ldloc_dup_stloc_shares_map_backing_with_fuel() { let _ = step_once(&mut vm).expect("stloc should execute"); assert_eq!(vm.stack().len(), 1); - assert_shared_heap_backing(&vm.locals[0], &vm.stack()[0]); + assert_shared_heap_backing(&vm.instance.locals[0], &vm.stack()[0]); } #[test] @@ -1787,11 +2375,14 @@ fn interpreter_fuses_call_ret_without_fuel() { vec![OpCode::Call as u8, call_lo, call_hi, 1, OpCode::Ret as u8], ); let mut vm = Vm::new(program); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let outcome = step_once(&mut vm).expect("call should execute"); assert!(matches!(outcome, ExecOutcome::Halted)); - assert_eq!(vm.ip, 5, "tail-call fusion should consume trailing ret"); + assert_eq!( + vm.instance.ip, 5, + "tail-call fusion should consume trailing ret" + ); assert_eq!(vm.stack(), &[Value::Int(4)]); } @@ -1804,12 +2395,15 @@ fn interpreter_fuses_call_ret_when_fuel_enabled_if_tail_tick_available() { ); let mut vm = Vm::new(program); vm.set_fuel(1); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); // `step_once` bypasses the outer run-loop pre-tick, so this fuel only covers fused `ret`. let call = step_once(&mut vm).expect("call should execute"); assert!(matches!(call, ExecOutcome::Halted)); - assert_eq!(vm.ip, 5, "tail-call fusion should consume trailing ret"); + assert_eq!( + vm.instance.ip, 5, + "tail-call fusion should consume trailing ret" + ); assert_eq!(vm.stack(), &[Value::Int(4)]); assert_eq!(vm.get_fuel(), Some(0)); } @@ -1823,7 +2417,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_tail_tick_exhausted() { ); let mut vm = Vm::new(program); vm.set_fuel(0); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let err = match step_once(&mut vm) { Ok(_) => panic!("tail tick should fail with out-of-fuel"), @@ -1831,7 +2425,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_tail_tick_exhausted() { }; assert!(matches!(err, VmError::OutOfFuel { .. })); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "ret must remain pending when tail tick cannot be charged" ); assert_eq!(vm.stack(), &[Value::Int(4)]); @@ -1847,7 +2441,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_epoch_deadline_is_reached() { let mut vm = Vm::new(program); vm.set_epoch_deadline(0) .expect("setting epoch deadline should succeed"); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let err = match step_once(&mut vm) { Ok(_) => panic!("tail tick should fail with epoch deadline reached"), @@ -1855,7 +2449,7 @@ fn interpreter_call_ret_fusion_preserves_ip_when_epoch_deadline_is_reached() { }; assert!(matches!(err, VmError::EpochDeadlineReached { .. })); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "ret must remain pending when the epoch check trips during fused tail execution" ); assert_eq!(vm.stack(), &[Value::Int(4)]); @@ -1870,11 +2464,11 @@ fn run_consumes_two_ticks_for_call_ret_when_fuel_enabled() { ); let mut vm = Vm::new(program); vm.set_fuel(2); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let status = vm.run().expect("run should complete"); assert_eq!(status, VmStatus::Halted); - assert_eq!(vm.ip, 5); + assert_eq!(vm.instance.ip, 5); assert_eq!(vm.stack(), &[Value::Int(4)]); assert_eq!( vm.get_fuel(), @@ -1892,12 +2486,12 @@ fn run_yields_before_ret_in_call_ret_sequence_when_out_of_fuel() { ); let mut vm = Vm::new(program); vm.set_fuel(1); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let status = vm.run().expect("first run should yield"); assert_eq!(status, VmStatus::Yielded); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "fuel exhaustion should happen before trailing ret" ); assert_eq!(vm.stack(), &[Value::Int(4)]); @@ -1906,7 +2500,7 @@ fn run_yields_before_ret_in_call_ret_sequence_when_out_of_fuel() { vm.add_fuel(1).expect("recharging fuel should succeed"); let resumed = vm.resume().expect("resume should execute trailing ret"); assert_eq!(resumed, VmStatus::Halted); - assert_eq!(vm.ip, 5); + assert_eq!(vm.instance.ip, 5); assert_eq!(vm.stack(), &[Value::Int(4)]); } @@ -1923,12 +2517,12 @@ fn run_yields_before_ret_in_call_ret_sequence_when_epoch_deadline_is_reached() { vm.set_epoch_deadline(1) .expect("setting epoch deadline should succeed"); assert_eq!(vm.increment_epoch(), 1); - vm.stack.push(Value::string("tail")); + vm.instance.stack.push(Value::string("tail")); let status = vm.run().expect("first run should yield"); assert_eq!(status, VmStatus::Yielded); assert_eq!( - vm.ip, 4, + vm.instance.ip, 4, "epoch interruption should happen before trailing ret" ); assert_eq!(vm.last_yield_reason(), Some(VmYieldReason::Epoch)); @@ -1938,10 +2532,101 @@ fn run_yields_before_ret_in_call_ret_sequence_when_epoch_deadline_is_reached() { .resume() .expect("resume should auto re-arm the epoch deadline and execute trailing ret"); assert_eq!(resumed, VmStatus::Halted); - assert_eq!(vm.ip, 5); + assert_eq!(vm.instance.ip, 5); assert_eq!(vm.stack(), &[Value::Int(4)]); } +#[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. + let compiled = crate::compile_source( + r#" + pub fn run() -> int { + 42; + } + "#, + ) + .expect("invocation source should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("root frame should halt"), VmStatus::Halted); + + vm.run_ctx + .cancel(CancellationReason::Requested) + .expect("pre-cancellation should be accepted"); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + { + let _invocation = vm + .start_invocation(callable, 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" + ); +} + +#[test] +fn pre_cancelled_invocation_delivers_one_typed_error_then_fused_end() { + // Functional contract of the pre-cancelled path: exactly one typed + // Cancelled item, a fused end, and the cancellation consumed at the + // invocation boundary (a later invocation runs normally). + let compiled = crate::compile_source( + r#" + pub fn run() -> int { + 42; + } + "#, + ) + .expect("invocation source should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("root frame should halt"), VmStatus::Halted); + + vm.run_ctx + .cancel(CancellationReason::Requested) + .expect("pre-cancellation should be accepted"); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + { + let mut invocation = vm + .start_invocation(callable.clone(), vec![]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + CancellationReason::Requested, + )))) => {} + other => panic!("expected a typed cancellation item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); + } + + // A new invocation on the same VM must run to completion instead of + // being cancelled on arrival. + let mut second = vm + .start_invocation(callable, vec![]) + .expect("a new invocation may start after fusion"); + match second.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) => {} + other => panic!("the second invocation must complete normally, got {other:?}"), + } + assert!(matches!( + second.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + #[test] fn call_ret_fusion_pattern_requires_immediate_ret() { let [call_lo, call_hi] = BuiltinFunction::Len.call_index().to_le_bytes(); @@ -1950,7 +2635,7 @@ fn call_ret_fusion_pattern_requires_immediate_ret() { vec![OpCode::Call as u8, call_lo, call_hi, 1, OpCode::Ret as u8], ); let mut vm_with_ret = Vm::new(with_ret); - vm_with_ret.ip = 4; + vm_with_ret.instance.ip = 4; assert!(vm_with_ret.can_fuse_call_ret_pattern()); let wrong_next = Program::new( @@ -1958,11 +2643,60 @@ fn call_ret_fusion_pattern_requires_immediate_ret() { vec![OpCode::Call as u8, call_lo, call_hi, 1, OpCode::Nop as u8], ); let mut vm_wrong_next = Vm::new(wrong_next); - vm_wrong_next.ip = 4; + vm_wrong_next.instance.ip = 4; assert!(!vm_wrong_next.can_fuse_call_ret_pattern()); let no_next = Program::new(vec![], vec![OpCode::Call as u8, call_lo, call_hi, 1]); let mut vm_no_next = Vm::new(no_next); - vm_no_next.ip = 4; + vm_no_next.instance.ip = 4; assert!(!vm_no_next.can_fuse_call_ret_pattern()); } + +#[test] +fn program_cache_key_distinguishes_call_script_from_call_value() { + // A direct-only call lowers to `CallScript`; the same call through a + // materialized callable lowers to `CallValue`. The static cache identity + // must treat the two programs as different even when their metadata + // otherwise matches, because the native call boundary differs. + let direct = crate::compile_source("fn add2(value: int) -> int { value + 2 } add2(40);") + .expect("direct call source should compile"); + let materialized = + crate::compile_source("fn add2(value: int) -> int { value + 2 } let f = add2; f(40);") + .expect("materialized call source should compile"); + + let mut direct_vm = Vm::new(direct.program); + let mut materialized_vm = Vm::new(materialized.program); + let direct_key = direct_vm.ensure_program_cache_key(); + let materialized_key = materialized_vm.ensure_program_cache_key(); + assert_ne!( + direct_key, materialized_key, + "CallScript and CallValue programs must not share cache identity" + ); + + // The same direct program reproduces the same key across VMs. + let direct_repeat = crate::compile_source("fn add2(value: int) -> int { value + 2 } add2(40);") + .expect("direct call source should compile"); + let mut repeat_vm = Vm::new(direct_repeat.program); + assert_eq!( + repeat_vm.ensure_program_cache_key(), + direct_key, + "identical programs must share cache identity" + ); +} + +#[test] +fn native_callable_abi_version_covers_direct_script_calls() { + // `CallScript` adds a new native boundary helper and exit contract; the + // native callable ABI revision must reflect it so every directly coupled + // program/native cache is invalidated exactly once. + assert_eq!( + super::native::NATIVE_CALLABLE_ABI_VERSION, + 6, + "native callable ABI revision must cover direct script call semantics" + ); + let direct = crate::compile_source("fn add2(value: int) -> int { value + 2 } add2(40);") + .expect("direct call source should compile"); + let mut vm = Vm::new(direct.program); + let key = vm.ensure_program_cache_key(); + assert_ne!(key, 0, "cache key must be non-trivial"); +} diff --git a/src/vmbc.rs b/src/vmbc.rs index 1ac65b68..b6432c61 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -11,7 +11,7 @@ use crate::debug_info::{ArgInfo, DebugFunction, DebugInfo, LineInfo, LocalInfo}; use crate::vm::{HostImport, OpCode, Program, Value}; const MAGIC: [u8; 4] = *b"VMBC"; -const VERSION_V11: u16 = 11; +const VERSION_V12: u16 = 12; const FLAGS: u16 = 0; #[derive(Debug, Clone, PartialEq, Eq)] @@ -92,6 +92,16 @@ pub enum ValidationError { expected: u8, got: u8, }, + InvalidCallScriptTarget { + offset: usize, + prototype_id: u32, + }, + InvalidCallScriptArity { + offset: usize, + prototype_id: u32, + expected: u8, + got: u8, + }, InvalidJumpTarget { offset: usize, target: u32, @@ -129,6 +139,22 @@ impl std::fmt::Display for ValidationError { f, "invalid call arity {got} for import index {index} at offset {offset}, expected {expected}", ), + ValidationError::InvalidCallScriptTarget { + offset, + prototype_id, + } => write!( + f, + "invalid callscript prototype {prototype_id} at offset {offset}", + ), + ValidationError::InvalidCallScriptArity { + offset, + prototype_id, + expected, + got, + } => write!( + f, + "invalid callscript arity {got} for prototype {prototype_id} at offset {offset}, expected {expected}", + ), ValidationError::InvalidJumpTarget { offset, target } => write!( f, "invalid jump target {target} referenced by instruction at offset {offset}", @@ -241,7 +267,7 @@ fn read_constant(cursor: &mut Cursor<'_>, depth: usize) -> Result Result, WireError> { let mut out = Vec::new(); out.extend_from_slice(&MAGIC); - out.extend_from_slice(&VERSION_V11.to_le_bytes()); + out.extend_from_slice(&VERSION_V12.to_le_bytes()); out.extend_from_slice(&FLAGS.to_le_bytes()); write_u32_count("constants", program.constants.len(), &mut out)?; @@ -275,7 +301,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - if version != VERSION_V11 { + if version != VERSION_V12 { return Err(WireError::UnsupportedVersion(version)); } @@ -492,6 +518,19 @@ pub fn disassemble_program_with_options(program: &Program, options: DisassembleO truncated = true; } } + x if x == OpCode::CallScript as u8 => { + if let Some(prototype_id) = read_u32(code, &mut ip) { + if let Some(argc) = read_u8(code, &mut ip) { + instruction.push_str(&format!("callscript {prototype_id} {argc}")); + } else { + instruction.push_str("callscript "); + truncated = true; + } + } else { + instruction.push_str("callscript "); + truncated = true; + } + } x if x == OpCode::Shl as u8 => instruction.push_str("shl"), x if x == OpCode::Shr as u8 => instruction.push_str("shr"), @@ -765,6 +804,43 @@ fn analyze_program( expected_bytes: 1, })?; } + x if x == OpCode::CallScript as u8 => { + let prototype_id = + read_u32(code, &mut ip).ok_or(ValidationError::TruncatedOperand { + offset: start, + opcode, + expected_bytes: 5, + })?; + let argc = read_u8(code, &mut ip).ok_or(ValidationError::TruncatedOperand { + offset: start, + opcode, + expected_bytes: 5, + })?; + let Some(prototype) = program.callable_prototypes.get(prototype_id as usize) else { + return Err(ValidationError::InvalidCallScriptTarget { + offset: start, + prototype_id, + }); + }; + // `CallScript` is a static script-function call: a + // host-import prototype must never be routed to the host + // path (the VM rejects it with `InvalidCallablePrototype`), + // so reject it deterministically here as well. + if !matches!(prototype.target, CallableTarget::ScriptFunction(_)) { + return Err(ValidationError::InvalidCallScriptTarget { + offset: start, + prototype_id, + }); + } + if argc != prototype.arity { + return Err(ValidationError::InvalidCallScriptArity { + offset: start, + prototype_id, + expected: prototype.arity, + got: argc, + }); + } + } other => { return Err(ValidationError::InvalidOpcode { diff --git a/tests/builtins/io_async_tests.rs b/tests/builtins/io_async_tests.rs new file mode 100644 index 00000000..f054dfb0 --- /dev/null +++ b/tests/builtins/io_async_tests.rs @@ -0,0 +1,106 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use vm::{Value, Vm, VmError, VmStatus, compile_source}; + +fn run_source(source: &str) -> Result, VmError> { + let compiled = + compile_source(&format!("use io;\n{source}")).expect("async io source should compile"); + let mut vm = Vm::new(compiled.program); + super::async_test_bridge::install(&mut vm); + + let mut status = vm.run()?; + loop { + match status { + VmStatus::Halted => return Ok(vm.stack().to_vec()), + VmStatus::Yielded => status = vm.resume()?, + VmStatus::Waiting(_) => { + vm.wait_for_host_op_blocking()?; + status = vm.resume()?; + } + } + } +} + +#[test] +fn async_io_round_trips_file_operations_through_host_driver() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!("pd-vm-async-io-{}-{nonce}", std::process::id())); + + let stack = run_source(&format!( + r#" + let handle = io::open("{}", "w"); + io::write(handle, "host-driven"); + io::flush(handle); + io::close(handle); + io::exists("{}"); + "#, + path.display(), + path.display() + )) + .expect("async io program should complete"); + + assert_eq!(stack.last(), Some(&Value::Bool(true))); + assert_eq!( + std::fs::read_to_string(&path).expect("written file should exist"), + "host-driven" + ); + let _ = std::fs::remove_file(path); +} + +#[test] +fn async_io_read_line_preserves_buffered_data_between_calls() { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should follow Unix epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "pd-vm-async-read-line-{}-{nonce}", + std::process::id() + )); + std::fs::write(&path, "first\nsecond\n").expect("fixture should be written"); + + let stack = run_source(&format!( + r#" + let handle = io::open("{}", "r"); + io::read_line(handle); + let second = io::read_line(handle); + io::close(handle); + second; + "#, + path.display() + )) + .expect("async read_line program should complete"); + + assert_eq!(stack.last(), Some(&Value::string("second\n"))); + let _ = std::fs::remove_file(path); +} + +#[cfg(unix)] +#[test] +fn async_io_popen_reads_through_tokio_process_pipe() { + let stack = run_source( + r#" + let handle = io::popen("printf async-process", "r"); + let output = io::read_all(handle); + io::close(handle); + output; + "#, + ) + .expect("async popen program should complete"); + + assert_eq!(stack.last(), Some(&Value::string("async-process"))); +} + +#[test] +fn io_implementations_do_not_create_private_threads_or_runtimes() { + let async_source = include_str!("../../src/builtins/runtime/io/async_io.rs"); + let blocking_source = include_str!("../../src/builtins/runtime/io/blocking.rs"); + + assert!(!async_source.contains("thread::Builder")); + assert!(!async_source.contains("runtime::Builder")); + assert!(!async_source.contains("spawn_blocking")); + assert!(!blocking_source.contains("thread::Builder")); +} diff --git a/tests/builtins/io_builtin_edge_tests.rs b/tests/builtins/io_builtin_edge_tests.rs index 0e58dc0c..b301fbea 100644 --- a/tests/builtins/io_builtin_edge_tests.rs +++ b/tests/builtins/io_builtin_edge_tests.rs @@ -1,4 +1,12 @@ -use vm::{Value, Vm, VmError, VmStatus, compile_source}; +use vm::{ + BuiltinFunction, CapabilityProfile, HostFunctionRegistry, IoHostExt, IoPolicy, Value, Vm, + VmError, VmStatus, compile_source, +}; + +#[cfg(unix)] +use std::path::PathBuf; +#[cfg(unix)] +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; fn run_source(source: &str) -> Result, VmError> { let wrapped = format!("use io;\n{source}"); @@ -28,6 +36,372 @@ fn run_source_host_error(source: &str) -> String { } } +#[test] +fn io_policy_denies_process_launch_when_process_capability_is_disabled() { + let compiled = compile_source( + r#" + use io; + io::popen("exit 0", "r"); + "#, + ) + .expect("source should compile"); + let mut registry = HostFunctionRegistry::restricted(); + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::IoPopen) + .build(), + ); + let mut vm = Vm::new(compiled.program); + vm.configure_io(IoPolicy::default()); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + let error = vm.run().expect_err("process launch should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("process capability"))); +} + +#[test] +fn io_policy_denies_paths_outside_allowed_roots() { + let compiled = compile_source( + r#" + use io; + io::exists("Cargo.toml"); + "#, + ) + .expect("source should compile"); + let mut registry = HostFunctionRegistry::restricted(); + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::IoExists) + .build(), + ); + let mut vm = Vm::new(compiled.program); + vm.configure_io(IoPolicy::default()); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + let error = vm.run().expect_err("path should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("allowed roots"))); +} + +#[test] +fn restricted_registry_defaults_to_deny_when_io_host_state_is_absent() { + let compiled = compile_source( + r#" + use io; + io::exists("Cargo.toml"); + "#, + ) + .expect("source should compile"); + let mut registry = HostFunctionRegistry::restricted(); + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::IoExists) + .build(), + ); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + let error = vm + .run() + .expect_err("missing IO host state should use the deny-by-default policy"); + assert!(matches!(error, VmError::HostError(message) if message.contains("allowed roots"))); +} + +#[cfg(unix)] +#[test] +fn io_policy_limits_write_size() { + let path = unique_temp_path("policy-write-limit"); + let compiled = compile_source(&format!( + r#" + use io; + let handle = io::open("{}", "w"); + io::write(handle, "four"); + "#, + path.display() + )) + .expect("source should compile"); + let policy = IoPolicy { + allowed_roots: vec![std::env::temp_dir().display().to_string()], + allow_write: true, + max_write_bytes: 3, + ..IoPolicy::default() + }; + let mut registry = HostFunctionRegistry::restricted(); + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::IoOpen) + .allow_builtin(BuiltinFunction::IoWrite) + .build(), + ); + let mut vm = Vm::new(compiled.program); + vm.configure_io(policy); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + assert!(matches!( + vm.run().expect("open should start"), + VmStatus::Waiting(_) + )); + vm.wait_for_host_op_blocking() + .expect("open should complete"); + let error = vm.resume().expect_err("oversized write should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("write limit"))); + let _ = std::fs::remove_file(path); +} + +#[cfg(unix)] +#[test] +fn io_policy_limits_read_all_size() { + let path = unique_temp_path("policy-read-limit"); + std::fs::write(&path, "four").expect("fixture should be written"); + let compiled = compile_source(&format!( + r#" + use io; + let handle = io::open("{}", "r"); + io::read_all(handle); + "#, + path.display() + )) + .expect("source should compile"); + let policy = IoPolicy { + allowed_roots: vec![std::env::temp_dir().display().to_string()], + max_read_bytes: 3, + ..IoPolicy::default() + }; + let mut registry = HostFunctionRegistry::restricted(); + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::IoOpen) + .allow_builtin(BuiltinFunction::IoReadAll) + .build(), + ); + let mut vm = Vm::new(compiled.program); + vm.configure_io(policy); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + assert!(matches!( + vm.run().expect("open should start"), + VmStatus::Waiting(_) + )); + vm.wait_for_host_op_blocking() + .expect("open should complete"); + assert!(matches!( + vm.resume().expect("read should start"), + VmStatus::Waiting(_) + )); + let error = vm + .wait_for_host_op_blocking() + .expect_err("oversized read should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("read limit"))); + let _ = std::fs::remove_file(path); +} + +#[cfg(unix)] +#[test] +fn io_policy_limits_read_line_size() { + let path = unique_temp_path("policy-read-line-limit"); + std::fs::write(&path, "four\n").expect("fixture should be written"); + let compiled = compile_source(&format!( + r#" + use io; + let handle = io::open("{}", "r"); + io::read_line(handle); + "#, + path.display() + )) + .expect("source should compile"); + let policy = IoPolicy { + allowed_roots: vec![std::env::temp_dir().display().to_string()], + max_read_bytes: 3, + ..IoPolicy::default() + }; + let mut registry = HostFunctionRegistry::restricted(); + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::IoOpen) + .allow_builtin(BuiltinFunction::IoReadLine) + .build(), + ); + let mut vm = Vm::new(compiled.program); + vm.configure_io(policy); + registry + .bind_vm_cached(&mut vm) + .expect("profile should bind"); + + assert!(matches!( + vm.run().expect("open should start"), + VmStatus::Waiting(_) + )); + vm.wait_for_host_op_blocking() + .expect("open should complete"); + assert!(matches!( + vm.resume().expect("read should start"), + VmStatus::Waiting(_) + )); + let error = vm + .wait_for_host_op_blocking() + .expect_err("oversized line should be denied"); + assert!(matches!(error, VmError::HostError(message) if message.contains("read limit"))); + let _ = std::fs::remove_file(path); +} + +#[cfg(unix)] +fn unique_temp_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should follow the Unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!("pd-vm-{label}-{}-{nonce}", std::process::id())) +} + +#[cfg(unix)] +fn process_exists(process_id: i32) -> bool { + // SAFETY: signal zero performs existence/permission checking without sending a signal. + let result = unsafe { libc::kill(process_id, 0) }; + result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[test] +fn blocking_io_runs_after_callback_registration_without_spawning_a_worker() { + let source = include_str!("../../src/builtins/runtime/io/blocking.rs"); + let schedule = source + .split_once("fn schedule_io_task(") + .expect("schedule_io_task should exist") + .1 + .split_once("fn runtime_host_error(") + .expect("schedule_io_task should precede runtime_host_error") + .0; + let callback_registration = schedule + .find(".insert(ResourceTypeId::CALLBACK, receiver)") + .expect("schedule_io_task should register its callback receiver"); + + assert!(!schedule.contains(".spawn(move ||")); + assert!(schedule[callback_registration..].contains("task()")); +} + +#[test] +fn popen_teardown_does_not_invoke_external_kill_programs() { + let source = include_str!("../../src/builtins/runtime/io/blocking.rs"); + assert!( + !source.contains("Command::new(\"kill\")"), + "Unix popen teardown must use the platform process API" + ); + assert!( + !source.contains("Command::new(\"taskkill\")"), + "Windows popen teardown must use the platform process API" + ); +} + +#[cfg(unix)] +#[test] +fn reset_terminates_popen_descendants() { + let child_pid_path = unique_temp_path("popen-descendant-pid"); + let command = format!( + "sleep 3600 & child=$!; echo $child > {}; wait", + child_pid_path.display() + ); + let compiled = compile_source(&format!( + r#" + use io; + io::popen("{command}", "r"); + "# + )) + .expect("descendant popen source should compile"); + let mut vm = Vm::new(compiled.program); + + let first = vm.run().expect("popen should start"); + assert!(matches!(first, VmStatus::Waiting(_))); + vm.wait_for_host_op_blocking() + .expect("popen should complete"); + + let pid_deadline = Instant::now() + Duration::from_secs(2); + while !child_pid_path.exists() && Instant::now() < pid_deadline { + std::thread::sleep(Duration::from_millis(5)); + } + let child_pid = std::fs::read_to_string(&child_pid_path) + .expect("popen command should publish its descendant pid") + .trim() + .parse::() + .expect("descendant pid should be numeric"); + assert!(process_exists(child_pid), "descendant should be running"); + + vm.reset_for_reuse(); + + let exit_deadline = Instant::now() + Duration::from_secs(2); + while process_exists(child_pid) && Instant::now() < exit_deadline { + std::thread::sleep(Duration::from_millis(5)); + } + let _ = std::fs::remove_file(&child_pid_path); + assert!( + !process_exists(child_pid), + "popen descendant {child_pid} survived VM reset" + ); +} + +#[cfg(unix)] +#[test] +#[ignore = "blocking IO runs the read on the caller thread"] +fn reset_interrupts_a_blocked_popen_read_within_a_bounded_time() { + let compiled = compile_source( + r#" + use io; + let handle = io::popen("sleep 3600", "r"); + io::read_all(handle); + "#, + ) + .expect("blocking popen source should compile"); + let mut vm = Vm::new(compiled.program); + + let first = vm.run().expect("popen should start"); + assert!(matches!(first, VmStatus::Waiting(_))); + vm.wait_for_host_op_blocking() + .expect("popen should complete"); + let second = vm.resume().expect("read_all should start"); + assert!(matches!(second, VmStatus::Waiting(_))); + std::thread::sleep(Duration::from_millis(25)); + + let started = Instant::now(); + vm.reset_for_reuse(); + assert!( + started.elapsed() < Duration::from_secs(2), + "reset exceeded bounded I/O teardown window: {:?}", + started.elapsed() + ); +} + +#[cfg(unix)] +#[test] +fn reset_reaps_a_popen_child_before_completion_is_polled() { + let compiled = compile_source( + r#" + use io; + io::popen("sleep 3599", "r"); + "#, + ) + .expect("popen source should compile"); + let mut vm = Vm::new(compiled.program); + + let status = vm.run().expect("popen should enter waiting state"); + assert!(matches!(status, VmStatus::Waiting(_))); + std::thread::sleep(Duration::from_millis(100)); + + let started = Instant::now(); + vm.reset_for_reuse(); + assert!( + started.elapsed() < Duration::from_secs(2), + "reset exceeded queued-completion teardown window: {:?}", + started.elapsed() + ); +} + #[test] fn io_open_rejects_unsupported_mode() { let err = run_source_host_error( @@ -119,3 +493,33 @@ fn io_flush_on_read_handle_is_a_noop_true() { .expect("program should execute"); assert_eq!(stack.last(), Some(&Value::Bool(true))); } + +#[test] +fn io_close_rejects_a_stale_resource_handle() { + let err = run_source_host_error( + r#" + let handle = io::open("Cargo.toml", "r"); + io::close(handle); + io::close(handle); + "#, + ); + assert!( + err.contains("resource_already_closed"), + "unexpected error message: {err}" + ); +} + +#[test] +fn io_handles_cannot_cross_vm_resource_arenas() { + let stack = run_source(r#"io::open("Cargo.toml", "r");"#) + .expect("first VM should open a file resource"); + let Value::Int(handle) = stack.last().expect("open should return a handle") else { + panic!("open should return an integer resource handle"); + }; + + let err = run_source_host_error(&format!("io::close({handle});")); + assert!( + err.contains("resource_handle_wrong_table"), + "unexpected error message: {err}" + ); +} diff --git a/tests/builtins/stdlib_tests.rs b/tests/builtins/stdlib_tests.rs index cc1674f1..d6df463d 100644 --- a/tests/builtins/stdlib_tests.rs +++ b/tests/builtins/stdlib_tests.rs @@ -14,6 +14,8 @@ fn run_rustscript_spec(path: &Path) -> Vec { ); let mut vm = Vm::new(compiled.program); + #[cfg(feature = "async")] + super::async_test_bridge::install(&mut vm); loop { let status = vm.run().expect("spec vm should run"); match status { diff --git a/tests/builtins_tests.rs b/tests/builtins_tests.rs index e4f54996..e591e8a6 100644 --- a/tests/builtins_tests.rs +++ b/tests/builtins_tests.rs @@ -1,7 +1,16 @@ #![cfg(feature = "runtime")] +#[cfg(feature = "async")] +#[path = "support/async_test_bridge.rs"] +mod async_test_bridge; + +#[cfg(not(feature = "async"))] #[path = "builtins/io_builtin_edge_tests.rs"] mod io_builtin_edge_tests; +#[cfg(feature = "async")] +#[path = "builtins/io_async_tests.rs"] +mod io_async_tests; + #[path = "builtins/stdlib_tests.rs"] mod stdlib_tests; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index f6967e09..6c7fa3f0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,8 +1,8 @@ #![allow(unused_imports)] pub use vm::{ - Assembler, BytecodeBuilder, CallOutcome, CompileSourceFileOptions, Compiler, Expr, - HostArgsFunction, HostFunction, HostFunctionRegistry, Program, SourceFlavor, + Assembler, BytecodeBuilder, CallOutcome, CapabilityProfile, CompileSourceFileOptions, Compiler, + Expr, HostArgsFunction, HostFunction, HostFunctionRegistry, Program, SourceFlavor, StaticHostArgsFunction, Stmt, Store, Value, Vm, VmStatus, assemble, compile_source, compile_source_file, compile_source_file_with_options, compile_source_with_flavor, }; @@ -131,6 +131,7 @@ pub enum CompileErrorKind { CallableUsedAsValue, NonCallableLocal, LocalSlotOverflow, + FrameLocalLimitExceeded, CallableArityMismatch, BreakOutsideLoop, ContinueOutsideLoop, @@ -141,6 +142,7 @@ pub enum CompileErrorKind { InvalidFieldAccess, FunctionParameterTypeConflict, StrictTypingRequired, + UnresolvedModuleCall, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -166,6 +168,9 @@ fn compile_error_kind(err: &vm::CompileError) -> CompileErrorKind { vm::CompileError::CallableUsedAsValue => CompileErrorKind::CallableUsedAsValue, vm::CompileError::NonCallableLocal(_) => CompileErrorKind::NonCallableLocal, vm::CompileError::LocalSlotOverflow(_) => CompileErrorKind::LocalSlotOverflow, + vm::CompileError::FrameLocalLimitExceeded { .. } => { + CompileErrorKind::FrameLocalLimitExceeded + } vm::CompileError::CallableArityMismatch { .. } => CompileErrorKind::CallableArityMismatch, vm::CompileError::BreakOutsideLoop => CompileErrorKind::BreakOutsideLoop, vm::CompileError::ContinueOutsideLoop => CompileErrorKind::ContinueOutsideLoop, @@ -184,6 +189,7 @@ fn compile_error_kind(err: &vm::CompileError) -> CompileErrorKind { CompileErrorKind::FunctionParameterTypeConflict } vm::CompileError::StrictTypingRequired { .. } => CompileErrorKind::StrictTypingRequired, + vm::CompileError::UnresolvedModuleCall => CompileErrorKind::UnresolvedModuleCall, } } diff --git a/tests/compiler/compiler_common_tests.rs b/tests/compiler/compiler_common_tests.rs index 2dce7039..b527f7cb 100644 --- a/tests/compiler/compiler_common_tests.rs +++ b/tests/compiler/compiler_common_tests.rs @@ -1,6 +1,7 @@ #[path = "../common/mod.rs"] mod common; use common::*; +use std::collections::HashMap; use vm::OpCode; const LOCAL_SLOT_COMPAT_THRESHOLD: usize = 8; @@ -269,6 +270,244 @@ fn compiler_reuses_slots_with_large_programs_that_call_script_functions() { assert_eq!(status, VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(399)]); } + +/// Generate the storage-shaped frame-local dispatch program: 77 named +/// functions (32 branch leaves each calling a same-frame helper, plus 13 +/// extra leaves) and a 32-branch dispatcher whose branch live sets union the +/// callee footprints. Each callee owns two parameters and one local. +fn frame_local_dispatch_source() -> String { + let mut source = String::new(); + for idx in 0..32usize { + source.push_str(&format!( + "fn h_{idx}(a: int, b: int) -> int {{\n let t = a + b;\n t;\n}}\n" + )); + source.push_str(&format!( + "fn f_{idx}(a: int, b: int) -> int {{\n let t = a + b;\n h_{idx}(t, a);\n}}\n" + )); + } + for idx in 32..45usize { + source.push_str(&format!( + "fn f_{idx}(a: int, b: int) -> int {{\n let t = a + b;\n t;\n}}\n" + )); + } + source.push_str("fn dispatch(idx: int) -> int {\n let mut acc = 0;\n"); + for idx in 0..32usize { + let keyword = if idx == 0 { "if" } else { "else if" }; + source.push_str(&format!( + " {keyword} idx == {idx} {{ acc = f_{idx}(acc, {}); }}\n", + idx + 1 + )); + } + source.push_str(" else { acc = f_32(acc, 33); }\n acc;\n}\n"); + source.push_str("dispatch(0);\ndispatch(31);\n"); + source +} + +#[test] +fn frame_local_dispatch_single_file_pressure_is_bounded() { + // Named script calls run in separate runtime frames, so callee body + // footprints must not inflate the caller frame's live set. The aggregate + // frame-local count must stay within per-frame pressure plus the + // currently required hidden callable slots (one per named function). + let source = frame_local_dispatch_source(); + let compiled = compile_source(&source).expect("frame-local dispatch program should compile"); + assert!( + compiled.locals <= 100, + "aggregate frame locals should stay within per-frame pressure plus callable slots, got {}", + compiled.locals + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(32)]); +} + +#[test] +fn frame_local_function_body_rejects_more_than_256_simultaneously_live_locals() { + // Genuine same-frame pressure inside a single function body must still + // fail with the frame-local limit: the frame-aware rules only remove + // cross-frame interference, never real per-frame pressure. + let live_count = (u8::MAX as usize) + 2; + let mut source = String::from("fn crowded() {\n"); + for idx in 0..live_count { + source.push_str(&format!(" let v{idx} = {idx};\n")); + } + source.push_str(" "); + for idx in 0..live_count { + if idx > 0 { + source.push_str(" + "); + } + source.push_str(&format!("v{idx}")); + } + source.push_str(";\n}\ncrowded();\n"); + + let err = match compile_source(&source) { + Ok(_) => panic!("compile should fail"), + Err(err) => err, + }; + match err { + vm::SourceError::Parse(parse_err) => { + assert!( + parse_err + .message + .contains("too many simultaneously live locals"), + "unexpected parse error: {parse_err:?}" + ); + } + other => panic!("expected parse error, got {other:?}"), + } +} + +#[test] +fn frame_local_root_accepts_256_simultaneously_live_locals_and_reads_highest_short_slot() { + // The 256-slot boundary must still compile and read the highest short + // slot; only aggregate pressure beyond 256 is rejected. The sum is the + // trailing expression so no extra local joins the live clique, and it is + // right-nested so codegen's string-classification recursion stays linear + // (it re-walks each left operand). + let live_count = (u8::MAX as usize) + 1; + let mut source = String::new(); + for idx in 0..live_count { + source.push_str(&format!("let v{idx} = {idx};\n")); + } + for idx in 0..live_count - 1 { + source.push_str(&format!("v{idx} + (")); + } + source.push_str(&format!("v{}", live_count - 1)); + for _ in 0..live_count - 1 { + source.push(')'); + } + source.push_str(";\n"); + + let compiled = compile_source(&source).expect("256-live program should compile"); + assert_eq!(compiled.locals, 256); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + let expected: i64 = (0..256).sum(); + assert_eq!(vm.stack(), &[Value::Int(expected)]); +} + +#[test] +fn frame_local_slot_reuse_across_recursive_call_frames() { + // `a` and `b` run in separate runtime frames even when they call each + // other recursively, so their locals must be free to share one relative + // slot: caller/callee cross-live edges would needlessly separate them. + // The program exceeds the slot-allocator compat threshold so physical + // slots are actually compacted. + let source = r#" + fn a(x: int) -> int { + let a1 = x + 1; + let a2 = a1 + 1; + let a3 = a2 + 1; + let a_local = a3 + 1; + if x > 0 => { b(x - 1) } else => { a_local } + } + fn b(y: int) -> int { + let b1 = y + 2; + let b2 = b1 + 2; + let b3 = b2 + 2; + let b_local = b3 + 2; + if y > 0 => { a(y - 1) } else => { b_local } + } + a(3); + "#; + let compiled = compile_source(source).expect("mutual recursion should compile"); + let debug = compiled + .program + .debug + .as_ref() + .expect("compiled program should include debug info"); + let a_local = debug + .locals + .iter() + .find(|local| local.name == "a_local") + .expect("a_local should be in debug info"); + let b_local = debug + .locals + .iter() + .find(|local| local.name == "b_local") + .expect("b_local should be in debug info"); + assert_eq!( + a_local.index, b_local.index, + "disjoint recursive frames should reuse the same relative slot" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(8)]); +} + +#[test] +fn frame_local_same_frame_values_keep_distinct_slots() { + // Negative control: two values genuinely live at the same time inside one + // function must receive different physical slots even though other frames + // may reuse them. The program exceeds the slot-allocator compat threshold + // so physical slots are actually compacted. + let source = r#" + fn overlap(a: int, b: int) -> int { + let p = a + 1; + let q = p + 1; + let x = a + b; + let y = q + x; + let s = y + 1; + let t = s + 1; + x + y + t; + } + overlap(3, 4); + "#; + let compiled = compile_source(source).expect("overlap should compile"); + let debug = compiled + .program + .debug + .as_ref() + .expect("compiled program should include debug info"); + let x = debug + .locals + .iter() + .find(|local| local.name == "x") + .expect("x should be in debug info"); + let y = debug + .locals + .iter() + .find(|local| local.name == "y") + .expect("y should be in debug info"); + assert_ne!( + x.index, y.index, + "simultaneously live values in one frame must keep distinct slots" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + // p = 4, q = 5, x = 7, y = 12, s = 13, t = 14, result = 33 + assert_eq!(vm.stack(), &[Value::Int(33)]); +} + +#[test] +fn frame_local_dispatch_data_pressure_is_small() { + // After frame isolation and milestone-6 slot omission the + // storage-shaped fixture needs only its own per-frame data slots: + // every named function is direct-only, so no hidden callable slots + // remain in the aggregate frame-local count. + let source = frame_local_dispatch_source(); + let compiled = compile_source(&source).expect("frame-local dispatch program should compile"); + let materialized = compiled.program.root_callable_bindings.len(); + let data_slots = compiled.locals.saturating_sub(materialized); + assert!( + data_slots <= 20, + "per-frame data pressure should stay small, got {data_slots} data slots" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(32)]); +} + #[test] fn compile_source_with_functions() { let source = include_str!("../../examples/example.rss"); @@ -1496,3 +1735,507 @@ fn stack_is_clean_after_halt_with_single_result() { // NOTE: function parameter slot cleanup is covered by // `script_function_frame_values_are_released_after_return` in // compiler_rustscript_tests.rs. + +#[test] +fn named_callable_materialization_omits_direct_only_slots() { + // Milestone 6: direct-only named functions keep a prototype but no + // hidden callable slot, root binding, or runtime self slot. Exported + // and value-referenced functions stay materialized. + let source = r#" + fn direct_helper(x: int) -> int { x + 1 } + fn exported_helper(x: int) -> int { x + 2 } + fn stored_helper(x: int) -> int { x + 3 } + pub fn exported(x: int) -> int { exported_helper(x) } + let stored = stored_helper; + direct_helper(1); + exported(1); + stored(1); + "#; + let compiled = compile_source(source).expect("classification program should compile"); + let program = &compiled.program; + assert_eq!( + program.callable_prototypes.len(), + 4, + "every named function keeps a prototype" + ); + let direct = program + .callable_prototypes + .iter() + .find(|prototype| prototype.parameter_slots.len() == 1) + .expect("direct-only helper prototype"); + // All four prototypes are FunctionItem here; identify the direct-only + // helper as the one with no root binding and no self slot. + let bound = program + .root_callable_bindings + .iter() + .map(|binding| binding.prototype_id) + .collect::>(); + assert_eq!(bound.len(), 2, "only stored and exported stay materialized"); + let direct_only = program + .callable_prototypes + .iter() + .enumerate() + .filter(|(index, _)| !bound.contains(&(*index as u32))) + .map(|(_, prototype)| prototype) + .collect::>(); + assert_eq!(direct_only.len(), 2, "two functions are direct-only"); + for prototype in direct_only { + assert_eq!( + prototype.self_slot, None, + "direct-only functions keep no runtime self slot" + ); + } + assert_eq!(direct.self_slot, None); + for binding in &program.root_callable_bindings { + let prototype = &program.callable_prototypes[binding.prototype_id as usize]; + assert!( + prototype.self_slot.is_some(), + "materialized functions keep their runtime self slot" + ); + } + assert!( + program + .exported_callables + .iter() + .any(|exported| exported.name == "exported"), + "exported function stays materialized and resolvable" + ); + assert!( + program.code.windows(1).any(|window| window[0] == 0x1A), + "direct-only call sites must emit CallScript" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(2), Value::Int(3), Value::Int(4)]); +} + +#[test] +fn named_callable_materialization_capturing_allocation_unchanged() { + // A capturing named function keeps its closure prototype, environment + // layout, and runtime self slot until the direct-call milestone: it can + // never use an environment-free direct call path. + let compiled = vm::compile_source_for_repl( + r#" + let captured = 42; + fn read_captured() { captured } + fn walk(n: int) -> int { + if n <= 0 => { captured } else => { walk(n - 1) } + } + read_captured; + walk(2); + "#, + ) + .expect("capturing named functions should compile"); + let program = &compiled.program; + let capturing = program + .callable_prototypes + .iter() + .filter(|prototype| !prototype.capture_slots.is_empty()) + .collect::>(); + assert_eq!( + capturing.len(), + 2, + "both capturing named functions keep their environment layouts" + ); + for prototype in capturing { + assert_eq!(prototype.kind, vm::CallableKind::Closure); + assert!( + prototype.self_slot.is_some(), + "capturing recursion retains the runtime self slot" + ); + } + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack().len(), 2, "callable value plus recursion result"); + assert!( + matches!(vm.stack()[0], Value::Callable(_)), + "the bare function value expression still materializes the callable" + ); + assert_eq!(vm.stack()[1], Value::Int(42)); +} + +#[test] +fn named_callable_without_facts_keeps_legacy_materialization() { + // The public `Compiler` API cannot supply milestone-5 classification + // facts (`set_callable_use_facts` is compiler-internal). A direct + // `Compiler::new().set_function_impls(...).compile_program(...)` path + // with a named script function must keep compiling under the legacy + // conservative contract: every named function stays materialized with + // its hidden callable slot. + let mut compiler = Compiler::new(); + compiler.set_function_impls(HashMap::from([( + 0u16, + vm::compiler::ir::FunctionImpl { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body_stmts: Vec::new(), + body_expr: vm::compiler::ir::Expr::Int(1), + body_expr_line: 1, + }, + )])); + compiler.set_function_decls(HashMap::from([( + 0u16, + vm::compiler::ir::FunctionDecl { + name: "legacy_helper".to_string(), + arity: 0, + index: 0, + args: Vec::new(), + arg_schemas: Vec::new(), + return_schema: None, + type_params: Vec::new(), + exported: false, + return_type: vm::ValueType::Int, + symbol: None, + }, + )])); + let stmts = [ + vm::compiler::ir::Stmt::FuncDecl { + name: "legacy_helper".to_string(), + index: 0, + arity: 0, + args: Vec::new(), + exported: false, + has_impl: true, + line: 1, + }, + vm::compiler::ir::Stmt::Expr { + expr: vm::compiler::ir::Expr::Call(0, Vec::new(), Vec::new()), + line: 1, + }, + ]; + let program = compiler + .compile_program(&stmts) + .expect("direct Compiler without facts must still compile named functions"); + + // Legacy materialization: the hidden callable slot and its root binding + // are retained even though no classification facts were provided. + assert_eq!(program.callable_prototypes.len(), 1); + assert!( + program.callable_prototypes[0].self_slot.is_some(), + "absent facts must conservatively retain the hidden callable slot" + ); + assert_eq!(program.root_callable_bindings.len(), 1); + + let mut vm = Vm::new(program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1)]); +} + +// --------------------------------------------------------------------------- +// Milestone 6: direct script-call lowering +// --------------------------------------------------------------------------- + +#[test] +fn direct_script_call_lowering_omits_ldloc_and_bindings() { + // A program whose only named functions are called directly must emit + // `CallScript` at every call site and no `Ldloc`/`Stloc` at all: no + // hidden callable slot exists to load. + let source = r#" + fn helper(x: int) -> int { x + 1 } + fn outer() -> int { helper(1) } + outer(); + "#; + let compiled = compile_source(source).expect("direct-only program should compile"); + let program = &compiled.program; + + assert_eq!( + program.root_callable_bindings.len(), + 0, + "direct-only functions get no root callable bindings" + ); + assert!( + program + .callable_prototypes + .iter() + .all(|prototype| prototype.self_slot.is_none()), + "direct-only functions keep no runtime self slot" + ); + assert_eq!( + program.code.iter().filter(|byte| **byte == 0x1A).count(), + 2, + "both call sites emit CallScript" + ); + // Every local access stays within the data-slot frame: no hidden + // callable slot exists to load or store. `helper` reads its parameter + // through `Ldloc`, so local loads are legal; they must never reference + // a slot at or beyond the data-slot count. + let mut ip = 0usize; + while ip < program.code.len() { + if matches!( + program.code[ip], + byte if byte == vm::OpCode::Ldloc as u8 || byte == vm::OpCode::Stloc as u8 + ) { + let operand = program.code[ip + 1]; + assert!( + usize::from(operand) < compiled.locals, + "local access {operand} exceeds the data-slot frame of {}", + compiled.locals + ); + } + ip += 1; + } + assert!( + !program.code.contains(&(vm::OpCode::CallValue as u8)), + "direct-only call sites must not use CallValue" + ); + // local_count is exactly the data-slot pressure: no callable slots. + assert_eq!(compiled.locals, 1, "one parameter slot for outer/helper"); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(2)]); +} + +#[test] +fn materialized_call_sites_retain_callvalue_lowering() { + // Exported, stored, and capturing named functions keep their hidden + // slot and are invoked through `Ldloc + CallValue`. + let compiled = vm::compile_source_for_repl( + r#" + let captured = 7; + fn read_captured() { captured } + pub fn exported(x: int) -> int { x + 1 } + let stored = exported; + read_captured; + exported(1); + stored(2); + "#, + ) + .expect("materialized program should compile"); + let program = &compiled.program; + assert_eq!( + program.root_callable_bindings.len(), + 1, + "only the exported function gets a root binding; the capturing function has none" + ); + assert_eq!( + program.code.iter().filter(|byte| **byte == 0x1A).count(), + 0, + "materialized call sites never emit CallScript" + ); + assert!( + program.code.contains(&(vm::OpCode::CallValue as u8)), + "materialized call sites keep CallValue" + ); + assert!( + program + .callable_prototypes + .iter() + .all(|prototype| prototype.self_slot.is_some()), + "materialized and capturing functions keep their runtime self slot" + ); +} + +#[test] +fn direct_script_call_forward_and_mutual_recursion_run() { + // Forward calls (callee declared later), direct recursion, and mutual + // recursion all execute through the direct script-call path. + let source = r#" + fn even(n: int) -> int { + if n == 0 => { 1 } else => { odd(n - 1) } + } + fn odd(n: int) -> int { + if n == 0 => { 0 } else => { even(n - 1) } + } + fn later(x: int) -> int { x * 2 } + fn countdown(n: int) -> int { + if n <= 0 => { 0 } else => { countdown(n - 1) } + } + later(21); + countdown(5); + even(10); + odd(7); + "#; + let compiled = compile_source(source).expect("recursion source should compile"); + assert!( + compiled + .program + .code + .iter() + .filter(|byte| **byte == 0x1A) + .count() + >= 4, + "direct recursion and mutual recursion use CallScript" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(42), Value::Int(0), Value::Int(1), Value::Int(1)] + ); +} + +#[test] +fn direct_script_call_generic_functions_use_their_prototype() { + // A generic function called directly is lowered through `CallScript` + // with a prototype, and generic function values keep using the + // specialized prototype machinery. + let source = r#" + fn identity(value: T) -> T { value } + identity::(42); + "#; + let compiled = compile_source(source).expect("generic call should compile"); + assert_eq!( + compiled.program.callable_prototypes.len(), + 2, + "the generic function keeps its base prototype plus the direct-call specialization" + ); + assert!( + compiled.program.code.contains(&0x1A), + "generic direct call emits CallScript" + ); + assert!( + compiled + .program + .callable_prototypes + .iter() + .all(|prototype| prototype.self_slot.is_none()), + "direct generic calls allocate no hidden callable slot" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + // Specialized generic values keep the substituted-schema prototype and + // the dynamic callable path. + let compiled = compile_source( + r#" + fn identity(value: T) -> T { value } + let f = identity::; + f(42); + "#, + ) + .expect("specialized value should compile"); + assert_eq!( + compiled.program.root_callable_bindings.len(), + 2, + "base plus specialized prototype both stay materialized" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); +} + +#[test] +fn direct_script_call_generic_resolves_instantiated_prototype_schema() { + // A direct generic call with explicit type arguments must resolve the + // prototype whose schema is the instantiated concrete schema, not the + // generic base prototype whose placeholder schema accepts all values. + // This keeps the runtime schema check and the wire-visible prototype + // metadata aligned with the call-site types. + let source = r#" + fn identity(value: T) -> T { value } + identity::(42); + "#; + let compiled = compile_source(source).expect("generic call should compile"); + let code = &compiled.program.code; + let mut ip = 0usize; + let mut targets = Vec::new(); + while ip < code.len() { + if code[ip] == vm::OpCode::CallScript as u8 { + let prototype_id = u32::from_le_bytes(code[ip + 1..ip + 5].try_into().unwrap()); + targets.push(prototype_id); + ip += 1 + vm::OpCode::CallScript.operand_len(); + } else { + ip += 1; + } + } + assert_eq!( + targets, + vec![1], + "direct generic call must target the specialized prototype" + ); + let prototype = &compiled.program.callable_prototypes[targets[0] as usize]; + let vm::compiler::TypeSchema::Callable { params, result } = prototype + .schema + .as_ref() + .expect("named prototype carries a callable schema") + else { + panic!("expected a callable schema"); + }; + assert_eq!( + params, + &[vm::compiler::TypeSchema::Int], + "specialized prototype schema must use the instantiated parameter type" + ); + assert_eq!( + result.as_ref(), + &vm::compiler::TypeSchema::Int, + "specialized prototype schema must use the instantiated result type" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + // The static checker still rejects wrong-typed instantiations at + // compile time; the instantiated schema on the direct prototype is the + // runtime backstop and the wire-visible identity for the call site. + let rejected = compile_source( + r#" + fn identity(value: T) -> T { value } + identity::("not an int"); + "#, + ); + assert!( + matches!( + rejected, + Err(vm::SourceError::Compile( + vm::CompileError::CallableArgumentTypeMismatch { .. } + )) + ), + "wrong-typed generic instantiation must be rejected at compile time" + ); +} + +#[test] +fn direct_script_call_exported_resolution_is_unchanged() { + // `ExportedCallable.local_slot` and `resolve_exported_callable` keep + // working when other functions are direct-only. + let compiled = compile_source( + r#" + fn hidden_helper(x: int) -> int { x + 1 } + pub fn exported(x: int) -> int { hidden_helper(x) } + exported(41); + "#, + ) + .expect("exported program should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + let resolved = vm + .resolve_exported_callable("exported") + .expect("exported callable must resolve"); + assert!( + matches!(resolved, Value::Callable(_)), + "resolved exported value is a callable" + ); +} + +#[test] +fn direct_script_call_pressure_improves_with_slot_omission() { + // The 77-function dispatch fixture: every named function is called + // directly, so zero hidden callable slots remain and the aggregate + // frame-local count falls to the data-slot pressure. + let source = frame_local_dispatch_source(); + let compiled = compile_source(&source).expect("frame-local dispatch program should compile"); + assert!( + compiled.locals <= 30, + "direct-only functions must not consume hidden callable slots, got {}", + compiled.locals + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(32)]); +} diff --git a/tests/compiler/compiler_rustscript_tests.rs b/tests/compiler/compiler_rustscript_tests.rs index 0ccb66ef..7c230c34 100644 --- a/tests/compiler/compiler_rustscript_tests.rs +++ b/tests/compiler/compiler_rustscript_tests.rs @@ -163,6 +163,8 @@ fn rustscript_io_namespace_builtin_calls_are_supported() { "#; let compiled = compile_source(source).expect("compile should succeed"); let mut vm = Vm::new(compiled.program); + #[cfg(feature = "async")] + super::async_test_bridge::install(&mut vm); loop { let status = vm.run().expect("vm should run"); @@ -532,6 +534,8 @@ fn compile_source_file_with_rustscript_complex_fixture() { std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/example_complex.rss"); let compiled = compile_source_file(path.as_path()).expect("compile should succeed"); let mut vm = Vm::new(compiled.program); + #[cfg(feature = "async")] + super::async_test_bridge::install(&mut vm); for func in &compiled.functions { match func.name.as_str() { @@ -761,7 +765,8 @@ fn named_function_recursion_uses_runtime_frames_and_hits_depth_limit() { compiled .program .code - .contains(&(vm::OpCode::CallValue as u8)) + .contains(&(vm::OpCode::CallScript as u8)), + "non-capturing direct recursion lowers through CallScript" ); assert_eq!(compiled.program.script_functions.len(), 1); @@ -795,16 +800,16 @@ fn repeated_named_calls_share_one_emitted_body() { 1 ); let mut ip = 0usize; - let mut callvalue_count = 0usize; + let mut callscript_count = 0usize; while ip < compiled.program.code.len() { let opcode = vm::OpCode::try_from(compiled.program.code[ip]) .expect("compiler should emit valid opcodes"); - if opcode == vm::OpCode::CallValue { - callvalue_count += 1; + if opcode == vm::OpCode::CallScript { + callscript_count += 1; } ip += 1 + opcode.operand_len(); } - assert_eq!(callvalue_count, 3); + assert_eq!(callscript_count, 3); let mut runtime = vm::Vm::new(compiled.program.with_local_count(compiled.locals)); assert_eq!( @@ -2642,8 +2647,8 @@ fn compile_source_file_rustscript_imports_merge_with_scoped_locals() { debug .locals .iter() - .any(|local| local.name == "module::shared"), - "module-scoped local should remain visible in debug metadata" + .any(|local| local.name.ends_with("::shared") && local.name != "shared"), + "module-scoped local should remain visible in debug metadata with a deterministic module-identity scope (milestone 4), not a bare file stem" ); assert!( debug.locals.iter().any(|local| local.name == "shared"), @@ -2858,7 +2863,10 @@ fn compile_source_file_rustscript_imported_direct_capture_multiple_move_is_rejec Err(err) => err, }; match err { - vm::SourcePathError::Source(vm::SourceError::Parse(parse)) => { + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + .. + } => { assert!( parse.message.contains("lut") && parse.message.contains("moved"), "unexpected parse error: {parse:?}" @@ -3802,3 +3810,25 @@ fn rustscript_generic_schema_errors_are_reported() { run_source_error_cases(&cases); } + +#[test] +fn rustscript_strict_stream_emit_accepts_any_payload() { + // In strict RustScript, `stream::emit` is the one host function whose + // `any` payload is accepted at compile time; the per-item event bound is + // validated at runtime by the invocation stream. The exemption is tied to + // the authoritative runtime builtin identity (see the compiler unit test + // `stream_emit_any_payload_exemption_requires_authoritative_builtin_identity`), + // so a same-name function registered through another catalog cannot + // inherit it. + compile_source( + r#" + use stream; + pub fn run() -> int { + stream::emit({"a": 1, "b": 2}); + stream::emit("text"); + 42; + } + "#, + ) + .expect("strict stream::emit with any payloads must compile"); +} diff --git a/tests/compiler/diagnostics_tests.rs b/tests/compiler/diagnostics_tests.rs index 6e68eba3..7e2926c8 100644 --- a/tests/compiler/diagnostics_tests.rs +++ b/tests/compiler/diagnostics_tests.rs @@ -95,16 +95,19 @@ pub fn ok() { let _ = fs::remove_dir(&root); match result { - Err(SourcePathError::Source(SourceError::Compile(compile))) => { + Err(SourcePathError::SourceWithMap { + error: SourceError::Compile(compile), + sources, + }) => { assert_eq!( compile.source_name(), Some(module_path.to_string_lossy().as_ref()) ); assert_eq!(compile.line(), Some(2)); - let mut source_map = SourceMap::new(); - source_map.add_source(module_path.display().to_string(), module_source); - let rendered = render_compile_error(&source_map, &compile, false); + // Milestone 5: the compilation-wide map travels with the error, + // so the rendered diagnostic reads the owning module source. + let rendered = render_compile_error(&sources, &compile, false); assert!(rendered.contains(&format!("{}:2:1", module_path.display()))); assert!(rendered.contains("let broken = if cond => {")); assert!(rendered.contains("int vs string")); @@ -393,3 +396,97 @@ fn myfn(v: T) { "generic schema local should not be reported as unknown, got {warnings:?}" ); } + +#[test] +fn frame_local_limit_diagnostic_reports_real_counts() { + // Aggregate frame pressure beyond 256 (200 genuinely live data slots in + // one function plus 60 exported callables: 60 exported helpers that stay + // materialized under milestone-6 lowering) must report the real counts + // instead of the old 65535 sentinel. The helpers are exported so they + // keep hidden callable slots; direct-only helpers would be omitted and + // the aggregate would fit. The sum is right-nested so codegen's + // string-classification recursion stays linear (it re-walks each left + // operand; left-nested sums of this size are exponential there). + let mut source = String::new(); + for idx in 0..60usize { + source.push_str(&format!("pub fn helper_{idx}() -> int {{ 0 }}\n")); + } + source.push_str("fn crowded() -> int {\n"); + for idx in 0..200usize { + source.push_str(&format!(" let v{idx} = {idx};\n")); + } + source.push_str(" "); + for idx in 0..200usize - 1 { + source.push_str(&format!("v{idx} + (")); + } + source.push_str("v199"); + for _ in 0..200usize - 1 { + source.push(')'); + } + source.push_str(";\n}\ncrowded();\n"); + + let err = match compile_source(&source) { + Ok(_) => panic!("aggregate frame pressure should fail to compile"), + Err(err) => err, + }; + let compile = match err { + vm::SourceError::Compile(compile) => compile, + other => panic!("expected compile error, got {other:?}"), + }; + match compile { + vm::CompileError::FrameLocalLimitExceeded { + data_slots, + callable_slots, + total_slots, + max_slots, + } => { + assert_eq!(data_slots, 200, "data slot count should be real"); + assert_eq!(callable_slots, 60, "callable slot count should be real"); + assert_eq!(total_slots, 260, "total should be the real aggregate"); + assert_eq!(max_slots, 256, "short bytecode ceiling should be 256"); + } + other => panic!("expected FrameLocalLimitExceeded, got {other:?}"), + } + + let mut source_map = SourceMap::new(); + source_map.add_source("inline.rss", &source); + let rendered = render_compile_error(&source_map, &compile, false); + assert!( + rendered.contains( + "frame requires 260 local slots (200 data + 60 callable); short bytecode supports 256" + ), + "unexpected diagnostic: {rendered}" + ); + assert!( + !rendered.contains("65535"), + "diagnostic must not report the old sentinel slot: {rendered}" + ); +} + +#[test] +fn frame_local_limit_diagnostic_reports_saturated_overflow_counts() { + // A saturated aggregate (usize overflow) must report the saturated counts + // rather than fabricating a concrete slot number. + let mut source_map = SourceMap::new(); + source_map.add_source("inline.rss", ""); + let err = vm::CompileError::FrameLocalLimitExceeded { + data_slots: usize::MAX - 5, + callable_slots: 5, + total_slots: usize::MAX, + max_slots: 256, + }; + let rendered = render_compile_error(&source_map, &err, false); + let expected = format!( + "frame requires {} local slots ({} data + 5 callable); short bytecode supports 256", + usize::MAX, + usize::MAX - 5 + ); + assert!( + rendered.contains(&expected), + "unexpected diagnostic: {rendered}" + ); + assert!( + !rendered.contains("65535"), + "diagnostic must not report the old sentinel slot: {rendered}" + ); +} diff --git a/tests/compiler/frontend_plugin_tests.rs b/tests/compiler/frontend_plugin_tests.rs index e6dcdfd8..9bc9ec6f 100644 --- a/tests/compiler/frontend_plugin_tests.rs +++ b/tests/compiler/frontend_plugin_tests.rs @@ -36,6 +36,8 @@ impl SourcePlugin for ConstantPlugin { function_impls: HashMap::new(), stmt_sources: Vec::new(), function_sources: HashMap::new(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), }) } } diff --git a/tests/compiler/module_import_tests.rs b/tests/compiler/module_import_tests.rs index 1973cb0b..d7a50c77 100644 --- a/tests/compiler/module_import_tests.rs +++ b/tests/compiler/module_import_tests.rs @@ -16,7 +16,9 @@ fn temp_module_root(prefix: &str) -> PathBuf { ); let root = std::env::temp_dir().join(unique); std::fs::create_dir_all(&root).expect("temp module root should be created"); - root + // Module identities are canonical for existing files; keep expected paths + // canonical too so assertions match under symlinked temp directories. + root.canonicalize().unwrap_or(root) } fn write_source(path: &Path, source: &str, description: &str) { @@ -69,6 +71,128 @@ fn compile_source_file_module_override_path_redirects_import_spec() { remove_module_root(&root); } +#[test] +fn nested_module_override_parse_error_preserves_source_text_and_path() { + let root = temp_module_root("vm_rustscript_nested_override_error_test"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::virtual::nested as nested; + nested::run(); + "#, + "main source", + ); + let override_source = "pub fn run( {"; + let options = CompileSourceFileOptions::new() + .with_module_override_source("virtual/nested.rss", override_source); + + let error = match compile_source_file_with_options(&main_path, options) { + Ok(_) => panic!("invalid override module should fail"), + Err(error) => error, + }; + match error { + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + .. + } => { + assert!(parse.message.contains("virtual/nested.rss")); + } + error => panic!("expected source-aware nested error, got {error:?}"), + } + + remove_module_root(&root); +} + +#[test] +fn nested_module_strict_unknown_diagnostic_keeps_module_source() { + let root = temp_module_root("vm_rustscript_nested_strict_diag_test"); + let main_path = root.join("main.rss"); + let nested_path = root.join("nested.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + write_source( + &nested_path, + "pub fn run() -> unknown { 1 }", + "nested source", + ); + + let error = match compile_source_file(&main_path) { + Ok(_) => panic!("unknown nested annotation should fail in strict RustScript"), + Err(error) => error, + }; + match error { + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + .. + } => { + assert!(parse.message.contains(&nested_path.display().to_string())); + } + error => panic!("expected nested strict diagnostic, got {error:?}"), + } + + remove_module_root(&root); +} + +#[test] +fn strict_nested_diagnostic_path_is_consistent_across_option_entry_points() { + let root_source = "use self::nested as nested;\nnested::run();\n"; + let nested_source = "pub fn run() -> unknown { 1 }"; + let options = + CompileSourceFileOptions::new().with_module_override_source("nested.rss", nested_source); + + let in_memory_error = match vm::compile_source_with_flavor_and_options( + root_source, + SourceFlavor::RustScript, + options.clone(), + ) { + Ok(_) => panic!("strict nested annotation should fail"), + Err(error) => error, + }; + match in_memory_error { + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + .. + } => { + assert!(parse.message.contains("__pd_vm_inmemory__/nested.rss")); + } + error => panic!("expected nested strict diagnostic, got {error:?}"), + } + + let root = temp_module_root("vm_rustscript_nested_strict_entry_test"); + let main_path = root.join("main.rss"); + let at_path_error = match vm::compile_source_at_path_with_flavor_and_options( + &main_path, + root_source, + SourceFlavor::RustScript, + options, + ) { + Ok(_) => panic!("strict nested annotation should fail"), + Err(error) => error, + }; + match at_path_error { + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(parse), + .. + } => { + assert!( + parse + .message + .contains(&root.join("nested.rss").display().to_string()) + ); + } + error => panic!("expected nested strict diagnostic, got {error:?}"), + } + + remove_module_root(&root); +} + #[test] fn compile_source_file_rustscript_named_import_is_selective() { let root = temp_module_root("vm_rustscript_selective_import_test"); @@ -104,7 +228,10 @@ fn compile_source_file_rustscript_named_import_is_selective() { assert!( matches!( err, - vm::SourcePathError::Source(vm::SourceError::Parse(vm::ParseError { ref message, .. })) + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(vm::ParseError { ref message, .. }), + .. + } if message.contains("unknown function 'add_two'") ), "expected unknown function error, got {err:?}" @@ -211,7 +338,10 @@ fn compile_source_file_rustscript_module_exports_only_pub_functions() { assert!( matches!( err, - vm::SourcePathError::Source(vm::SourceError::Parse(vm::ParseError { ref message, .. })) + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(vm::ParseError { ref message, .. }), + .. + } if message.contains("unknown function 'private_add'") ), "expected unknown function error, got {err:?}" @@ -318,3 +448,571 @@ fn compile_source_file_imported_module_dynamic_slice_end_bindings_work() { remove_module_root(&root); } + +#[test] +fn nested_module_namespace_import_rewrites_sibling_calls() { + let root = temp_module_root("vm_rustscript_nested_namespace_import_test"); + write_source( + &root.join("sibling.rss"), + r#" + pub fn value() -> int { 7 } + "#, + "sibling source", + ); + write_source( + &root.join("nested.rss"), + r#" + use self::sibling as sibling; + pub fn run() -> int { sibling::value() } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("nested namespace import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(7)]); + + remove_module_root(&root); +} + +#[test] +fn nested_module_named_import_rewrites_sibling_calls() { + let root = temp_module_root("vm_rustscript_nested_named_import_test"); + write_source( + &root.join("sibling.rss"), + r#" + pub fn value() -> int { 11 } + "#, + "sibling source", + ); + write_source( + &root.join("nested.rss"), + r#" + use self::sibling::{value}; + pub fn run() -> int { value() } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("nested named import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(11)]); + remove_module_root(&root); +} + +#[test] +fn nested_module_super_import_resolves_parent_directory_sibling() { + let root = temp_module_root("vm_rustscript_nested_super_import_test"); + write_source( + &root.join("shared.rss"), + r#" + pub fn value() -> int { 13 } + "#, + "parent sibling source", + ); + let package = root.join("pkg"); + std::fs::create_dir_all(&package).expect("package directory should be created"); + write_source( + &package.join("nested.rss"), + r#" + use super::shared as shared; + pub fn run() -> int { shared::value() } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::pkg::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("nested super import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(13)]); + + remove_module_root(&root); +} + +#[test] +fn nested_module_missing_sibling_reports_nested_source() { + let root = temp_module_root("vm_rustscript_nested_missing_import_test"); + write_source( + &root.join("nested.rss"), + r#" + use self::missing as missing; + pub fn run() -> int { missing::value() } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + + let error = match compile_source_file(&main_path) { + Ok(_) => panic!("missing nested sibling should fail"), + Err(error) => error, + }; + assert!( + matches!( + error, + vm::SourcePathError::Io(ref io_error) + if io_error.kind() == std::io::ErrorKind::NotFound + ), + "missing nested sibling should remain a filesystem error: {error:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn explicit_self_import_cycle_is_detected_after_path_normalization() { + let root = temp_module_root("vm_rustscript_self_cycle_import_test"); + write_source( + &root.join("a.rss"), + r#" + use self::b as b; + pub fn run() -> int { b::run() } + "#, + "a source", + ); + write_source( + &root.join("b.rss"), + r#" + use self::a as a; + pub fn run() -> int { a::run() } + "#, + "b source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::a as a; + a::run(); + "#, + "main source", + ); + + let error = match compile_source_file(&main_path) { + Ok(_) => panic!("explicit self import cycle should fail"), + Err(error) => error, + }; + assert!( + matches!(error, vm::SourcePathError::ImportCycle(_)), + "expected import cycle error, got {error:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn nested_module_does_not_reexport_transitive_imports() { + let root = temp_module_root("vm_rustscript_nested_export_boundary_test"); + write_source( + &root.join("sibling.rss"), + r#" + pub fn leaf() -> int { 19 } + "#, + "sibling source", + ); + write_source( + &root.join("nested.rss"), + r#" + use self::sibling as sibling; + pub fn run() -> int { sibling::leaf() } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::leaf(); + "#, + "main source", + ); + + let error = match compile_source_file(&main_path) { + Ok(_) => panic!("transitive import should not be re-exported"), + Err(error) => error, + }; + assert!( + matches!( + error, + vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(vm::ParseError { ref message, .. }), + .. + } + if message.contains("nested::leaf") || message.contains("unknown namespace") + ), + "expected transitive export boundary error, got {error:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn nested_module_rewrite_preserves_utf8_values_byte_for_byte() { + let root = temp_module_root("vm_rustscript_nested_utf8_import_test"); + write_source( + &root.join("sibling.rss"), + r#" + // 猫のコメント: the sibling module is untouched by rewriting. + pub fn echo(value: string) -> string { value } + "#, + "sibling source", + ); + write_source( + &root.join("nested.rss"), + r#" + /* 前置ブロック: 猫 */ + use self::sibling as sibling; + use self::sibling::{echo as echo_named}; + pub fn run() -> string { + let namespace_value = sibling::echo("猫"); + let named_value = echo_named("🐱 にゃん"); + // 行コメント: 猫 + let joined = namespace_value + named_value; + joined + } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("nested utf-8 imports should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::string("猫🐱 にゃん")], + "UTF-8 literals must survive namespace and named import rewriting" + ); + + remove_module_root(&root); +} + +#[test] +fn nested_module_consecutive_super_import_resolves_two_levels_up() { + let root = temp_module_root("vm_rustscript_consecutive_super_import_test"); + write_source( + &root.join("shared.rss"), + r#" + pub fn value() -> int { 17 } + "#, + "root sibling source", + ); + let package = root.join("pkg").join("sub"); + std::fs::create_dir_all(&package).expect("package directory should be created"); + write_source( + &package.join("nested.rss"), + r#" + use super::super::shared as shared; + pub fn run() -> int { shared::value() } + "#, + "two-level nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::pkg::sub::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("consecutive super import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(17)]); + + remove_module_root(&root); +} + +#[test] +fn path_aliases_resolve_to_single_module_identity() { + let root = temp_module_root("vm_rustscript_path_alias_identity_test"); + write_source( + &root.join("a.rss"), + r#" + pub fn value() -> int { 23 } + "#, + "module a source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::a as a; + use a as a2; + let x = a::value(); + let y = a2::value(); + x + y; + "#, + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("lexically distinct path aliases should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(46)]); + + remove_module_root(&root); +} + +#[test] +fn import_cycle_detected_across_lexically_distinct_aliases() { + let root = temp_module_root("vm_rustscript_cycle_alias_identity_test"); + write_source( + &root.join("a.rss"), + r#" + use self::b as b; + pub fn run() -> int { b::run() } + "#, + "a source", + ); + write_source( + &root.join("b.rss"), + r#" + use a as a; + pub fn run() -> int { a::run() } + "#, + "b source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::a as a; + a::run(); + "#, + "main source", + ); + + let error = match compile_source_file(&main_path) { + Ok(_) => panic!("lexically distinct cycle aliases should fail"), + Err(error) => error, + }; + assert!( + matches!(error, vm::SourcePathError::ImportCycle(_)), + "expected import cycle error across alias forms, got {error:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn duplicate_import_aliases_are_idempotent() { + let root = temp_module_root("vm_rustscript_duplicate_alias_import_test"); + write_source( + &root.join("sibling.rss"), + r#" + pub fn value() -> int { 29 } + "#, + "sibling source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::sibling as sib; + use self::sibling as sib; + sib::value(); + "#, + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("duplicate import aliases should be idempotent"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(29)]); + + remove_module_root(&root); +} + +#[test] +fn nested_module_host_namespace_import_stays_host() { + let root = temp_module_root("vm_rustscript_nested_host_namespace_test"); + write_source( + &root.join("nested.rss"), + r#" + use math; + pub fn run() -> float { math::sqrt(81) } + "#, + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::nested as nested; + nested::run(); + "#, + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("nested host namespace import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Float(9.0)]); + + remove_module_root(&root); +} + +#[test] +fn frame_local_dispatch_module_split_pressure_is_bounded() { + // The same 77-function/32-branch call graph as the single-file frame-local + // dispatch test, split across semantic modules. Named-call pressure must + // be independent of import discovery order and linker local-base + // assignment: callee body footprints stay inside their own frames. + let fixture_root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("modules") + .join("frame_local_dispatch"); + let main_path = fixture_root.join("main.rss"); + let compiled = compile_source_file(&main_path) + .expect("frame-local module dispatch program should compile"); + assert!( + compiled.locals <= 100, + "aggregate frame locals should stay within per-frame pressure plus callable slots, got {}", + compiled.locals + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(32)]); +} + +#[test] +fn named_callable_materialization_module_split_same_name_materialization() { + // Two modules each declare a private `helper` with the same source name. + // Milestone 5 classification follows the resolved function identity, and + // milestone 6 lowering keeps every named function's prototype while + // omitting hidden slots for the direct-only helpers: each module's + // exported `run` stays materialized, and each module's `run` calls its + // own helper through the direct script-call path. + let root = temp_module_root("named_callable_materialization_same_name"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn run() { helper(); }\nfn helper() { 11; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn run() { helper(); }\nfn helper() { 22; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::run();\nbu::run();\n", + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("same-named module helpers should compile"); + let program = &compiled.program; + assert_eq!( + program.callable_prototypes.len(), + 4, + "each module's run and each module's same-named helper keep a prototype" + ); + assert_eq!( + program.root_callable_bindings.len(), + 2, + "only the exported run functions stay materialized with root bindings" + ); + assert_eq!( + program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_some()) + .count(), + 2, + "only the exported run functions keep their runtime self slot" + ); + assert_eq!( + program + .callable_prototypes + .iter() + .filter(|prototype| prototype.self_slot.is_none()) + .count(), + 2, + "the direct-only same-named helpers keep no runtime self slot" + ); + assert_eq!( + program.code.iter().filter(|byte| **byte == 0x1A).count(), + 2, + "each module's run calls its own helper through CallScript" + ); + + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(11), Value::Int(22)], + "each module's run must resolve its own same-named helper" + ); + + remove_module_root(&root); +} diff --git a/tests/compiler/semantic_module_m12_tests.rs b/tests/compiler/semantic_module_m12_tests.rs new file mode 100644 index 00000000..55f58492 --- /dev/null +++ b/tests/compiler/semantic_module_m12_tests.rs @@ -0,0 +1,260 @@ +//! Milestones 1-2 of the semantic module system: structured `use` parsing with +//! spans/clauses, deterministic module identities, same-stem uniqueness, and +//! the dedicated host-namespace path. + +#[path = "../common/mod.rs"] +mod common; + +use std::path::{Path, PathBuf}; + +use common::*; +use vm::{ + ImportClause, ParserDialect, SharedParserOptions, UsePathSegment, parse_source_with_dialect, +}; + +/// Minimal dialect for driving the shared frontend parser from tests. +struct TestDialect; + +impl ParserDialect for TestDialect {} + +static TEST_DIALECT: TestDialect = TestDialect; + +fn rustscript_options() -> SharedParserOptions { + SharedParserOptions { + source_id: 0, + allow_implicit_externs: false, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: false, + } +} + +fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + root.canonicalize().unwrap_or(root) +} + +fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source).unwrap_or_else(|err| panic!("{description} should write: {err}")); +} + +fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn parser_records_structured_use_nodes_with_spans_and_clauses() { + let source = "use self::pkg::nested as nested;\n\ + use sibling::{value as v, other};\n\ + use super::shared;\n\ + use io;\n\ + 1;\n"; + let ir = parse_source_with_dialect(source, &TEST_DIALECT, rustscript_options()) + .expect("source should parse"); + + let decls = &ir.use_declarations; + assert_eq!( + decls.len(), + 4, + "every use directive becomes a structured node" + ); + + // self-qualified namespace import. + assert_eq!( + decls[0].path, + vec![ + UsePathSegment::Self_, + UsePathSegment::Ident("pkg".to_string()), + UsePathSegment::Ident("nested".to_string()), + ] + ); + assert!( + matches!(&decls[0].clause, ImportClause::Namespace(alias) if alias == "nested"), + "namespace alias clause expected" + ); + assert_eq!(decls[0].line, 1); + + // Named import list with an alias. + assert_eq!( + decls[1].path, + vec![UsePathSegment::Ident("sibling".to_string())] + ); + match &decls[1].clause { + ImportClause::Named(named) => { + assert_eq!(named.len(), 2); + assert_eq!(named[0].imported, "value"); + assert_eq!(named[0].local, "v"); + assert_eq!(named[1].imported, "other"); + assert_eq!(named[1].local, "other"); + } + other => panic!("expected named clause, got {other:?}"), + } + assert_eq!(decls[1].line, 2); + + // super-qualified and bare builtin imports. + assert_eq!( + decls[2].path, + vec![ + UsePathSegment::Super, + UsePathSegment::Ident("shared".to_string()) + ] + ); + assert!(matches!(decls[2].clause, ImportClause::AllPublic)); + assert!(matches!(decls[3].clause, ImportClause::AllPublic)); + assert_eq!(decls[3].line, 4); + + // Every span covers exactly its directive text in the source. + for decl in decls { + assert!( + decl.span.lo < decl.span.hi, + "span must cover the directive: {decl:?}" + ); + let text = &source[decl.span.lo..decl.span.hi]; + assert!( + text.starts_with("use ") && text.ends_with(';'), + "span must cover the full directive, got {text:?}" + ); + } +} + +#[test] +fn parser_import_scan_mode_tolerates_file_module_calls() { + // The source-loader discovery parse must accept calls to functions that + // only the later prelude/rewrite step resolves: unknown direct calls and + // namespace calls through multi-segment file-module paths. + let source = "use self::nested as nested;\n\ + nested::run();\n\ + imported_helper(1);\n\ + 1;\n"; + let options = SharedParserOptions { + allow_implicit_externs: true, + import_scan_mode: true, + ..rustscript_options() + }; + let ir = parse_source_with_dialect(source, &TEST_DIALECT, options) + .expect("scan mode must tolerate unresolved module calls"); + assert_eq!(ir.use_declarations.len(), 1); + assert_eq!( + ir.use_declarations[0].path, + vec![ + UsePathSegment::Self_, + UsePathSegment::Ident("nested".to_string()) + ] + ); +} + +#[test] +fn same_stem_modules_in_different_directories_compile_and_run() { + let root = temp_module_root("semantic_m12_same_stem"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + + let a_module = a_dir.join("util.rss"); + let b_module = b_dir.join("util.rss"); + write_source(&a_module, "pub fn alpha() { 11; }\n", "a/util source"); + write_source(&b_module, "pub fn beta() { 22; }\n", "b/util source"); + + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::alpha();\nbu::beta();\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("same-stem modules should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(11), Value::Int(22)], + "both same-stem modules must resolve independently" + ); + + remove_module_root(&root); +} + +#[test] +fn host_namespace_imports_keep_dedicated_resolution_path() { + struct ExistsOverride; + + impl HostFunction for ExistsOverride { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + Ok(CallOutcome::Return(vec![Value::Bool(false)].into())) + } + } + + let source = "use io;\nio::exists(\"request_body\");\n"; + let compiled = compile_source(source).expect("host namespace import should compile"); + let mut vm = Vm::new(compiled.program); + vm.bind_function("io::exists", Box::new(ExistsOverride)); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Bool(false)]); +} + +#[test] +fn named_import_with_alias_through_self_resolves_structurally() { + let root = temp_module_root("semantic_m12_named_alias"); + let module_path = root.join("module.rss"); + write_source( + &module_path, + "pub fn echo(value) { value; }\n", + "module source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::module::{echo as e};\ne(42);\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("named alias import should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + remove_module_root(&root); +} + +#[test] +fn structured_import_syntax_rejects_crate_paths() { + let err = match compile_source("use crate::x;\n1;\n") { + Ok(_) => panic!("crate:: paths should be rejected"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("crate:: paths are not supported"), + "unexpected error: {message}" + ); +} + +#[test] +fn structured_import_syntax_rejects_import_keyword() { + let root = temp_module_root("semantic_m12_import_keyword"); + let main_path = root.join("main.rss"); + write_source(&main_path, "import \"./module.rss\";\n1;\n", "main source"); + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("legacy import syntax should be rejected"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("uses 'use', not 'import'"), + "unexpected error: {message}" + ); + remove_module_root(&root); +} diff --git a/tests/compiler/semantic_module_m3_tests.rs b/tests/compiler/semantic_module_m3_tests.rs new file mode 100644 index 00000000..80dae118 --- /dev/null +++ b/tests/compiler/semantic_module_m3_tests.rs @@ -0,0 +1,215 @@ +//! Milestone 3 of the semantic module system: declaration symbols owned by +//! modules, public export tables, imported-vs-local separation, duplicate +//! declaration diagnostics, same-named helpers across modules, and no +//! implicit transitive re-export — with bytecode behavior preserved. + +#[path = "../common/mod.rs"] +mod common; + +use std::path::{Path, PathBuf}; + +use common::*; + +fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + root.canonicalize().unwrap_or(root) +} + +fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source).unwrap_or_else(|err| panic!("{description} should write: {err}")); +} + +fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); +} + +/// `a/util` exports `alpha` and keeps `hidden` private; `b/util` exports +/// `beta`. Both modules declare a private helper named `helper`. +fn write_public_private_fixture(root: &Path) -> (PathBuf, PathBuf, PathBuf) { + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + + let a_module = a_dir.join("util.rss"); + write_source( + &a_module, + "pub fn alpha() { helper(); }\nfn helper() { 42; }\nfn hidden() { 7; }\n", + "a/util source", + ); + let b_module = b_dir.join("util.rss"); + write_source( + &b_module, + "pub fn beta() { helper(); }\nfn helper() { 42; }\n", + "b/util source", + ); + + let main_path = root.join("main.rss"); + (main_path, a_module, b_module) +} + +#[test] +fn same_named_helpers_across_modules_coexist() { + // Milestone 4 lifts the flat-merge limitation documented by milestone 3: + // same-named private helpers in independent modules now coexist, each + // resolved by its compiler-owned symbol. `alpha` and `beta` each call + // their own module's `helper`. + let root = temp_module_root("semantic_m3_same_helpers"); + let (main_path, _, _) = write_public_private_fixture(&root); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::alpha();\nbu::beta();\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("same-named helpers should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(42), Value::Int(42)], + "each module's helper must resolve within its own module" + ); + + remove_module_root(&root); +} + +#[test] +fn public_functions_are_importable_private_functions_are_not() { + let root = temp_module_root("semantic_m3_visibility"); + let (main_path, _, _) = write_public_private_fixture(&root); + + // Public export: `alpha` resolves through the namespace import. + write_source( + &main_path, + "use a::util as au;\nau::alpha();\n", + "main source", + ); + let compiled = compile_source_file(&main_path).expect("public export should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + // Private declaration: `hidden` is not in a/util's export table, so the + // call cannot resolve through the import. + write_source( + &main_path, + "use a::util as au;\nau::hidden();\n", + "main source", + ); + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("private functions must not be importable"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("hidden"), + "diagnostic should name the private function, got: {message}" + ); + + remove_module_root(&root); +} + +#[test] +fn transitive_imports_are_not_reexported() { + // a imports c and uses c::shared internally; the root imports only a. + // `shared` must stay out of a's export table: calling it from the root + // without a direct import is a diagnostic, not a silent re-export. + let root = temp_module_root("semantic_m3_no_reexport"); + let c_module = root.join("c.rss"); + write_source(&c_module, "pub fn shared() { 100; }\n", "c source"); + let a_module = root.join("a.rss"); + write_source( + &a_module, + "use self::c;\npub fn alpha() { c::shared(); }\n", + "a source", + ); + let main_path = root.join("main.rss"); + + write_source(&main_path, "use a;\nalpha();\nshared();\n", "main source"); + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("transitive imports must not be re-exported implicitly"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("shared"), + "diagnostic should name the non-reexported function, got: {message}" + ); + + // Positive control: importing c directly makes `shared` resolvable. + write_source( + &main_path, + "use a;\nuse c;\nalpha();\nshared();\n", + "main source", + ); + let compiled = compile_source_file(&main_path).expect("direct import should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(100), Value::Int(100)]); + + remove_module_root(&root); +} + +#[test] +fn imported_name_clashing_with_local_declaration_is_a_diagnostic() { + // The import prelude declares the imported name, so declaring the same + // name locally in the importing module is a duplicate diagnostic instead + // of a silent shadow. + let root = temp_module_root("semantic_m3_import_clash"); + let a_dir = root.join("a"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + let a_module = a_dir.join("util.rss"); + write_source(&a_module, "pub fn helper() { 1; }\n", "a/util source"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util;\nfn helper() { 2; }\nhelper();\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("imported name clashing with a local declaration must fail"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("conflicts with a local declaration") + || message.contains("duplicate function 'helper'"), + "unexpected diagnostic: {message}" + ); + + remove_module_root(&root); +} + +#[test] +fn duplicate_local_declaration_in_a_module_is_a_diagnostic() { + let root = temp_module_root("semantic_m3_dup_local"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "fn dup() { 1; }\nfn dup() { 2; }\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("duplicate local declarations must fail"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("duplicate function 'dup'"), + "unexpected diagnostic: {message}" + ); + + remove_module_root(&root); +} diff --git a/tests/compiler/semantic_module_m4_tests.rs b/tests/compiler/semantic_module_m4_tests.rs new file mode 100644 index 00000000..5a8e43d2 --- /dev/null +++ b/tests/compiler/semantic_module_m4_tests.rs @@ -0,0 +1,343 @@ +//! Milestone 4 of the semantic module system: calls resolve by compiler-owned +//! `SymbolId` before unit merge, the flat linker keys module functions by +//! symbol instead of by source name, and names are deterministically mangled +//! only at the flat bytecode boundary. Same-named declarations in independent +//! modules coexist; local bindings are scoped by full module identity instead +//! of a bare file stem. + +#[path = "../common/mod.rs"] +mod common; + +use std::path::{Path, PathBuf}; + +use common::*; + +fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + root.canonicalize().unwrap_or(root) +} + +fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source).unwrap_or_else(|err| panic!("{description} should write: {err}")); +} + +fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); +} + +/// `a/util` and `b/util` both export a public `run` (different bodies) and +/// each keeps a private helper named `helper` that its own `run` calls. +fn write_same_export_fixture(root: &Path) -> PathBuf { + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + + let a_module = a_dir.join("util.rss"); + write_source( + &a_module, + "pub fn run() { helper(); }\nfn helper() { 11; }\n", + "a/util source", + ); + let b_module = b_dir.join("util.rss"); + write_source( + &b_module, + "pub fn run() { helper(); }\nfn helper() { 22; }\n", + "b/util source", + ); + + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::run();\nbu::run();\n", + "main source", + ); + main_path +} + +#[test] +fn same_exported_function_name_in_two_namespaces_calls_separately() { + let root = temp_module_root("semantic_m4_same_export"); + let main_path = write_same_export_fixture(&root); + + let compiled = compile_source_file(&main_path).expect("same-named exports should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(11), Value::Int(22)], + "au::run and bu::run must resolve to their own module's export" + ); + + // The flat exported-callable table keeps both exports addressable: the + // first export keeps its bare name and the collision is deterministically + // mangled with the module identity. + let exported_names = vm + .program() + .exported_callables + .iter() + .map(|exported| exported.name.as_str()) + .collect::>(); + assert!( + exported_names.contains(&"run"), + "one export keeps the bare name: {exported_names:?}" + ); + assert_eq!( + exported_names + .iter() + .filter(|name| name.starts_with("run__m")) + .count(), + 1, + "the colliding export is deterministically mangled: {exported_names:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn same_named_private_helpers_are_resolved_within_their_own_module() { + let root = temp_module_root("semantic_m4_private_helpers"); + let main_path = write_same_export_fixture(&root); + + let compiled = compile_source_file(&main_path).expect("same-named helpers should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(11), Value::Int(22)], + "each module's private helper must be the one its own run calls" + ); + + remove_module_root(&root); +} + +#[test] +fn named_import_aliases_resolve_to_distinct_symbols() { + let root = temp_module_root("semantic_m4_named_aliases"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn emit(value) { value * 2; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn emit(value) { value * 3; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util::{emit as twice};\nuse b::util::{emit as thrice};\ntwice(4);\nthrice(4);\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("named alias imports should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(8), Value::Int(12)], + "each alias must call its own module's emit" + ); + + remove_module_root(&root); +} + +#[test] +fn local_functions_resolve_within_their_own_module() { + // `run` (pub) calls `local` (private) in both modules; the local calls + // must stay inside their declaring module even though both modules define + // same-named functions. + let root = temp_module_root("semantic_m4_local_functions"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn run() { local(); }\nfn local() { 1; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn run() { local(); }\nfn local() { 2; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::run();\nbu::run();\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("local functions should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(1), Value::Int(2)], + "each module's local function must resolve within its own module" + ); + + remove_module_root(&root); +} + +#[test] +fn ambiguous_direct_call_to_same_name_from_two_modules_is_a_diagnostic() { + // Both modules export `helper` and the root imports both without aliases: + // a bare `helper()` call cannot name a single symbol. The legacy pipeline + // reported a flat merge error; milestone 4 reports the ambiguity and asks + // for a namespace-qualified or named-import call. + let root = temp_module_root("semantic_m4_ambiguous_direct"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn helper() { 1; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn helper() { 2; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util;\nuse b::util;\nhelper();\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("ambiguous direct calls must be rejected"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("ambiguous"), + "diagnostic should report the ambiguity, got: {message}" + ); + assert!( + message.contains("helper"), + "diagnostic should name the ambiguous function, got: {message}" + ); + + // The same fixture compiles once the calls are namespace-qualified. + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::helper();\nbu::helper();\n", + "main source", + ); + let compiled = compile_source_file(&main_path).expect("qualified calls should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(2)]); + + remove_module_root(&root); +} + +#[test] +fn internal_lowering_is_deterministic_across_repeated_discovery() { + // The same fixture compiled twice must produce byte-identical bytecode: + // module ids, symbol ids, stub names, flat indices, and mangled names are + // all assigned deterministically from discovery order. + let root = temp_module_root("semantic_m4_deterministic"); + let main_path = write_same_export_fixture(&root); + + let compile_bytes = || { + let compiled = compile_source_file(&main_path).expect("compile should succeed"); + vm::encode_program(&compiled.program).expect("program should encode") + }; + + let first = compile_bytes(); + let second = compile_bytes(); + assert_eq!( + first, second, + "internal lowering must be deterministic across discovery passes" + ); + + remove_module_root(&root); +} + +#[test] +fn same_stem_modules_do_not_collide_local_binding_scope_names() { + // Two same-stem modules (`a/util`, `b/util`) both declare a local `x`. + // Milestone 4 scopes non-root locals by full module identity (never a + // bare file stem), so both survive the flat boundary with distinct names. + let root = temp_module_root("semantic_m4_no_basename_scope"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn alpha() { let x = 7; x; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn beta() { let x = 8; x; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::alpha();\nbu::beta();\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("same-stem modules should compile"); + let debug = compiled + .program + .debug + .as_ref() + .expect("compiled program should include debug info"); + let x_names = debug + .locals + .iter() + .filter(|local| local.name.ends_with("::x")) + .map(|local| local.name.as_str()) + .collect::>(); + assert_eq!( + x_names.len(), + 2, + "both modules' x locals must survive the flat boundary: {x_names:?}" + ); + assert!( + x_names.iter().all(|name| *name != "x"), + "non-root locals must be scoped by module identity, got: {x_names:?}" + ); + assert!( + x_names.iter().all(|name| name.contains("__m")), + "scope identity must encode the compiler-owned module id: {x_names:?}" + ); + assert!( + x_names[0] != x_names[1], + "same-stem modules must not share a scope identity: {x_names:?}" + ); + + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(7), Value::Int(8)]); + + remove_module_root(&root); +} diff --git a/tests/compiler/semantic_module_m5_tests.rs b/tests/compiler/semantic_module_m5_tests.rs new file mode 100644 index 00000000..023f3d7c --- /dev/null +++ b/tests/compiler/semantic_module_m5_tests.rs @@ -0,0 +1,457 @@ +//! Milestone 5 of the semantic module system: source-owned spans and +//! diagnostics through merge. +//! +//! Every span produced during load/parse/typing/merge references the semantic +//! module graph's `SourceId` space, and the compilation-wide `SourceMap` +//! travels with module-compile errors (`SourcePathError::SourceWithMap`), so +//! rendered diagnostics always read from the owning source. Merging units can +//! never reinterpret one module's offsets or lines against another file. + +#[path = "../common/mod.rs"] +mod common; + +use std::path::{Path, PathBuf}; + +use common::*; + +fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + root.canonicalize().unwrap_or(root) +} + +fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source).unwrap_or_else(|err| panic!("{description} should write: {err}")); +} + +fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); +} + +/// Render a module-compile error against the compilation-wide source map it +/// carries (milestone 5): the rendered diagnostic shows the owning file name, +/// line, and code frame. +fn render_path_error(err: &vm::SourcePathError) -> String { + match err { + vm::SourcePathError::SourceWithMap { error, sources } => match error { + vm::SourceError::Parse(parse) => vm::render_source_error(sources, parse, false), + vm::SourceError::Compile(compile) => vm::render_compile_error(sources, compile, false), + }, + other => vm::render_source_path_error(Path::new(""), other, false), + } +} + +#[test] +fn root_parse_error_renders_root_path_and_frame() { + let root = temp_module_root("semantic_m5_root_parse"); + let main_path = root.join("main.rss"); + write_source(&main_path, "fn run() {\nlet x = ;\n}\n", "main source"); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("invalid root source should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains(&main_path.display().to_string()), + "root diagnostic must name the root path, got:\n{rendered}" + ); + assert!( + rendered.contains("let x = ;"), + "root diagnostic must show the root code frame, got:\n{rendered}" + ); + assert!( + rendered.contains("--> "), + "root diagnostic must include a source frame, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn nested_module_parse_error_renders_from_owning_source() { + let root = temp_module_root("semantic_m5_nested_parse"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::nested as nested;\nnested::run();\n", + "main source", + ); + let nested_path = root.join("nested.rss"); + write_source(&nested_path, "pub fn run( {\n", "nested source"); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("malformed nested module should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains(&nested_path.display().to_string()), + "nested parse diagnostic must name the nested path, got:\n{rendered}" + ); + assert!( + rendered.contains("pub fn run( {"), + "nested parse diagnostic must show the nested code frame, got:\n{rendered}" + ); + assert!( + !rendered.contains("nested::run();"), + "nested parse diagnostic must not show the root frame, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn nested_module_typing_error_renders_from_owning_source() { + let root = temp_module_root("semantic_m5_nested_typing"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::broken as broken;\nbroken::run();\n", + "main source", + ); + let broken_path = root.join("broken.rss"); + write_source( + &broken_path, + "pub fn run() {\nlet cond = 1 == 1;\nlet value = if cond => {\n 1\n} else => {\n \"x\"\n};\nvalue\n}\n", + "broken source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("typed mismatched nested module should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains("compile error"), + "typing diagnostic must render as a compile error, got:\n{rendered}" + ); + assert!( + rendered.contains(&broken_path.display().to_string()), + "typing diagnostic must name the nested module, got:\n{rendered}" + ); + assert!( + rendered.contains("let value = if cond => {"), + "typing diagnostic must show the nested module's code frame, got:\n{rendered}" + ); + assert!( + rendered.contains("int vs string") || rendered.contains("incompatible"), + "typing diagnostic must keep its detail message, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn duplicate_function_error_renders_from_owning_source() { + let root = temp_module_root("semantic_m5_duplicate"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::dupmod as d;\nd::run();\n", + "main source", + ); + let dup_path = root.join("dupmod.rss"); + write_source( + &dup_path, + "fn run() { 1; }\nfn run() { 2; }\n", + "duplicate source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("duplicate declarations should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains("duplicate"), + "duplicate diagnostic must say 'duplicate', got:\n{rendered}" + ); + assert!( + rendered.contains(&dup_path.display().to_string()), + "duplicate diagnostic must name the owning module, got:\n{rendered}" + ); + assert!( + rendered.contains("fn run() { 2; }"), + "duplicate diagnostic must point at the redeclaration's frame, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn private_export_visibility_failure_renders_from_importing_source() { + // `hidden` is private in the module; the named import in main must fail + // and the diagnostic must render from main's own `use` line. + let root = temp_module_root("semantic_m5_visibility"); + let module_path = root.join("module.rss"); + write_source(&module_path, "fn hidden() { 1; }\n", "module source"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use module::{hidden};\nhidden();\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("importing a private function should fail"), + Err(err) => err, + }; + assert!( + err.to_string().contains("has no public function 'hidden'"), + "visibility diagnostic must name the missing public function, got: {err}" + ); + let rendered = render_path_error(&err); + assert!( + rendered.contains(&main_path.display().to_string()), + "visibility diagnostic must render from the importing module, got:\n{rendered}" + ); + assert!( + rendered.contains("use module::{hidden};"), + "visibility diagnostic must show the importing module's use line, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn unresolved_module_call_renders_from_owning_source() { + // `nested` imports `sibling` privately; main cannot call `leaf` through + // `nested` (no implicit transitive re-export). The unresolved call must + // render from main's own source. + let root = temp_module_root("semantic_m5_unresolved_call"); + let sibling_path = root.join("sibling.rss"); + write_source( + &sibling_path, + "pub fn leaf() -> int { 19 }\n", + "sibling source", + ); + let nested_path = root.join("nested.rss"); + write_source( + &nested_path, + "use self::sibling as sibling;\npub fn run() -> int { sibling::leaf() }\n", + "nested source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::nested as nested;\nnested::leaf();\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("calling a non re-exported function should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains(&main_path.display().to_string()), + "unresolved call diagnostic must name the calling module, got:\n{rendered}" + ); + assert!( + rendered.contains("nested::leaf();"), + "unresolved call diagnostic must show the call site frame, got:\n{rendered}" + ); + assert!( + !rendered.contains("pub fn run() -> int"), + "unresolved call diagnostic must not show the nested module's frame, got:\n{rendered}" + ); + + remove_module_root(&root); +} + +#[test] +fn same_line_number_in_different_modules_renders_each_owning_source() { + // Both modules fail strict typing on line 2, but each compilation must + // render its own file's line 2 text. + let root = temp_module_root("semantic_m5_same_line"); + let a_module = root.join("a.rss"); + write_source( + &a_module, + "pub fn run() {\nlet a: unknown = 1;\na\n}\n", + "a module source", + ); + let b_module = root.join("b.rss"); + write_source( + &b_module, + "pub fn run() {\nlet b: unknown = 2;\nb\n}\n", + "b module source", + ); + + let compile_entry = |entry_name: &str, module: &Path| { + let entry = root.join(entry_name); + let module_name = module + .file_stem() + .and_then(|stem| stem.to_str()) + .expect("stem"); + write_source( + &entry, + &format!("use self::{module_name} as m;\nm::run();\n"), + "entry source", + ); + compile_source_file(&entry) + }; + + let err_a = match compile_entry("main_a.rss", &a_module) { + Ok(_) => panic!("a module should fail strict typing"), + Err(err) => err, + }; + let rendered_a = render_path_error(&err_a); + assert!( + rendered_a.contains(&a_module.display().to_string()), + "a diagnostic must name a.rss, got:\n{rendered_a}" + ); + assert!( + rendered_a.contains("let a: unknown = 1;"), + "a diagnostic must show a.rss line 2, got:\n{rendered_a}" + ); + + let err_b = match compile_entry("main_b.rss", &b_module) { + Ok(_) => panic!("b module should fail strict typing"), + Err(err) => err, + }; + let rendered_b = render_path_error(&err_b); + assert!( + rendered_b.contains(&b_module.display().to_string()), + "b diagnostic must name b.rss, got:\n{rendered_b}" + ); + assert!( + rendered_b.contains("let b: unknown = 2;"), + "b diagnostic must show b.rss line 2, got:\n{rendered_b}" + ); + assert!( + !rendered_b.contains("let a: unknown = 1;"), + "b diagnostic must never show a.rss's line 2 text, got:\n{rendered_b}" + ); + + remove_module_root(&root); +} + +#[test] +fn in_memory_override_and_disk_modules_render_their_own_sources() { + // Disk module a/util and in-memory override b/util both fail strict + // typing on line 2 of their own text. The disk failure must render the + // file that exists on disk; the override failure must render the override + // text even though b/util.rss does not exist on disk. + let root = temp_module_root("semantic_m5_override_ownership"); + let a_dir = root.join("a"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + let a_module = a_dir.join("util.rss"); + write_source( + &a_module, + "pub fn alpha() {\nlet a: unknown = 1;\na\n}\n", + "a/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::alpha();\nbu::beta();\n", + "main source", + ); + + // Phase 1: a/util (disk) is the first failing module; the diagnostic must + // render the disk text of a/util.rss. + let options_phase1 = vm::CompileSourceFileOptions::new() + .with_module_override_source("b/util.rss", "pub fn beta() {\nlet b: unknown = 2;\nb\n}\n"); + let err_phase1 = match compile_source_file_with_options(&main_path, options_phase1) { + Ok(_) => panic!("disk module should fail strict typing"), + Err(err) => err, + }; + let rendered_phase1 = render_path_error(&err_phase1); + assert!( + rendered_phase1.contains(&a_module.display().to_string()), + "disk diagnostic must name a/util.rss, got:\n{rendered_phase1}" + ); + assert!( + rendered_phase1.contains("let a: unknown = 1;"), + "disk diagnostic must show the disk code frame, got:\n{rendered_phase1}" + ); + + // Phase 2: a/util (disk) is valid; the in-memory override for b/util is + // the failing module. The diagnostic must render the override text even + // though no b/util.rss exists on disk. + write_source(&a_module, "pub fn alpha() { 1; }\n", "a/util fixed source"); + let options_phase2 = vm::CompileSourceFileOptions::new() + .with_module_override_source("b/util.rss", "pub fn beta() {\nlet b: unknown = 2;\nb\n}\n"); + let err_phase2 = match compile_source_file_with_options(&main_path, options_phase2) { + Ok(_) => panic!("override module should fail strict typing"), + Err(err) => err, + }; + let rendered_phase2 = render_path_error(&err_phase2); + assert!( + rendered_phase2.contains("__pd_vm_inmemory__/b/util.rss") + || rendered_phase2.contains(&root.join("b/util.rss").display().to_string()), + "override diagnostic must name the virtual b/util identity, got:\n{rendered_phase2}" + ); + assert!( + rendered_phase2.contains("let b: unknown = 2;"), + "override diagnostic must show the override text frame, got:\n{rendered_phase2}" + ); + assert!( + !rendered_phase2.contains("let a: unknown = 1;"), + "override diagnostic must not show a/util's frame, got:\n{rendered_phase2}" + ); + + remove_module_root(&root); +} + +#[test] +fn in_memory_root_error_renders_virtual_path_and_frame() { + // `compile_source_with_flavor_and_options` compiles a virtual root; its + // parse error must render the virtual path and the in-memory frame. + let source = "use self::nested as nested;\nnested::run();\n"; + let options = vm::CompileSourceFileOptions::new() + .with_module_override_source("nested.rss", "pub fn run( {\n"); + + let err = match vm::compile_source_with_flavor_and_options( + source, + vm::SourceFlavor::RustScript, + options, + ) { + Ok(_) => panic!("malformed override module should fail"), + Err(err) => err, + }; + let rendered = render_path_error(&err); + assert!( + rendered.contains("__pd_vm_inmemory__/nested.rss"), + "in-memory diagnostic must name the virtual nested path, got:\n{rendered}" + ); + assert!( + rendered.contains("pub fn run( {"), + "in-memory diagnostic must show the override code frame, got:\n{rendered}" + ); + + // The same fixture through the at-path entry point renders the disk-path + // identity of the override instead. + let root = temp_module_root("semantic_m5_virtual_at_path"); + let main_path = root.join("main.rss"); + let at_path_err = match vm::compile_source_at_path_with_flavor_and_options( + &main_path, + source, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::new() + .with_module_override_source("nested.rss", "pub fn run( {\n"), + ) { + Ok(_) => panic!("malformed override module should fail"), + Err(err) => err, + }; + let rendered_at_path = render_path_error(&at_path_err); + assert!( + rendered_at_path.contains(&root.join("nested.rss").display().to_string()), + "at-path diagnostic must name the disk-path override identity, got:\n{rendered_at_path}" + ); + assert!( + rendered_at_path.contains("pub fn run( {"), + "at-path diagnostic must show the override code frame, got:\n{rendered_at_path}" + ); + + remove_module_root(&root); +} diff --git a/tests/compiler/semantic_module_m6_tests.rs b/tests/compiler/semantic_module_m6_tests.rs new file mode 100644 index 00000000..df9679c5 --- /dev/null +++ b/tests/compiler/semantic_module_m6_tests.rs @@ -0,0 +1,399 @@ +//! Milestone 6/7 verification: the semantic module pipeline is the sole +//! file-module path. +//! +//! These tests exercise the end-to-end module behavior that the removed +//! textual rewrite/prelude machinery used to provide: wildcard imports, +//! function values of imported functions, generic calls through every import +//! form, single-segment host-form namespace calls, deterministic output, and +//! import-order independence of behavior. + +#[path = "../common/mod.rs"] +mod common; + +use std::path::{Path, PathBuf}; + +use common::*; + +fn temp_module_root(prefix: &str) -> PathBuf { + let unique = format!( + "{prefix}_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock should be valid") + .as_nanos() + ); + let root = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&root).expect("temp module root should be created"); + root.canonicalize().unwrap_or(root) +} + +fn write_source(path: &Path, source: &str, description: &str) { + std::fs::write(path, source).unwrap_or_else(|err| panic!("{description} should write: {err}")); +} + +fn remove_module_root(root: &Path) { + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn wildcard_import_exposes_all_public_exports_directly_and_by_namespace() { + let root = temp_module_root("semantic_m6_wildcard"); + write_source( + &root.join("util.rss"), + "pub fn value() -> int { 5 }\npub fn double(x) { x * 2; }\nfn private_helper() { 99; }\n", + "util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::util::*;\nlet direct = value();\nlet ns = util::double(direct);\nns;\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("wildcard import should compile"); + assert!( + compiled.functions.is_empty(), + "wildcard imports must not produce host imports" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(10)]); + + remove_module_root(&root); +} + +#[test] +fn wildcard_import_does_not_expose_private_helpers() { + let root = temp_module_root("semantic_m6_wildcard_private"); + write_source( + &root.join("util.rss"), + "pub fn value() -> int { 5 }\nfn private_helper() { 99; }\n", + "util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::util::*;\nprivate_helper();\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("wildcard import must not expose private functions"), + Err(err) => err, + }; + assert!( + err.to_string() + .contains("unknown function 'private_helper'"), + "unexpected diagnostic: {err}" + ); + + remove_module_root(&root); +} + +#[test] +fn imported_function_values_resolve_to_module_symbols() { + let root = temp_module_root("semantic_m6_function_values"); + write_source( + &root.join("util.rss"), + "pub fn add_one(x) { x + 1; }\n", + "util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::util;\nlet f = add_one;\nf(41);\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("function value import should compile"); + assert!( + compiled.functions.is_empty(), + "imported function values must not produce host imports" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + remove_module_root(&root); +} + +#[test] +fn generic_calls_work_through_named_namespace_and_alias_import_forms() { + let root = temp_module_root("semantic_m6_generic_forms"); + let a_dir = root.join("a"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn wrap(value: T) { let copied = value; [copied]; }\n", + "a/util source", + ); + write_source( + &root.join("helpers.rss"), + "pub fn wrap(value: T) { let copied = value; [copied]; }\n", + "helpers source", + ); + + // Named import (direct), all-public namespace call, and aliased + // namespace call all carry explicit type arguments through to the + // exported type parameters. + let main_path = root.join("main.rss"); + write_source( + &main_path, + r#" + use self::helpers::{wrap as direct_wrap}; + use self::helpers; + use a::util as au; + + let named = direct_wrap::(1); + let namespace_value = helpers::wrap::(2); + let aliased = au::wrap::(3); + named.length + namespace_value.length + aliased.length; + "#, + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("generic import forms should compile"); + assert!( + compiled.functions.is_empty(), + "generic imported calls must not produce host imports" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(3)]); + + remove_module_root(&root); +} + +#[test] +fn single_segment_module_import_namespace_calls_stay_module_calls() { + // `use module; module::fn()` parses as a host-form call (the parser + // cannot know `module` is a file module); the loader must fix it up to a + // module call instead of emitting a host import. + let root = temp_module_root("semantic_m6_single_segment_ns"); + write_source( + &root.join("module.rss"), + "pub fn public_add(x) { x + 1; }\n", + "module source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use module;\nmodule::public_add(41);\n", + "main source", + ); + + let compiled = + compile_source_file(&main_path).expect("single-segment namespace call should compile"); + assert!( + compiled.functions.is_empty(), + "file-module namespace calls must not become host imports" + ); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + remove_module_root(&root); +} + +#[test] +fn single_segment_named_import_missing_member_stays_unknown_function() { + let root = temp_module_root("semantic_m6_single_segment_named"); + write_source( + &root.join("module.rss"), + "pub fn add_one(x) { x + 1; }\n", + "module source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use module::{add_one};\nadd_two(40);\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("unlisted member must fail"), + Err(err) => err, + }; + assert!( + err.to_string().contains("unknown function 'add_two'"), + "unexpected diagnostic: {err}" + ); + + remove_module_root(&root); +} + +#[test] +fn same_exported_name_from_two_modules_resolves_per_namespace() { + // Two modules exporting the same name, imported through aliases; the + // final flat boundary keeps both addressable with deterministic + // module-identity mangling for the colliding name. + let root = temp_module_root("semantic_m6_same_exports"); + let a_dir = root.join("a"); + let b_dir = root.join("b"); + std::fs::create_dir_all(&a_dir).expect("a dir should be created"); + std::fs::create_dir_all(&b_dir).expect("b dir should be created"); + write_source( + &a_dir.join("util.rss"), + "pub fn helper() { 1; }\n", + "a/util source", + ); + write_source( + &b_dir.join("util.rss"), + "pub fn helper() { 2; }\n", + "b/util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use a::util as au;\nuse b::util as bu;\nau::helper();\nbu::helper();\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("same-name exports should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(2)]); + + remove_module_root(&root); +} + +#[test] +fn compiled_output_is_deterministic_across_repeated_compilations() { + let root = temp_module_root("semantic_m6_deterministic_bytes"); + write_source( + &root.join("util.rss"), + "pub fn helper() { 1; }\npub fn other() { helper() + 1; }\n", + "util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::util;\nhelper();\nother();\n", + "main source", + ); + + let compile = || { + let compiled = compile_source_file(&main_path).expect("compile should succeed"); + let function_names = compiled + .functions + .iter() + .map(|func| func.name.clone()) + .collect::>(); + (compiled.program.code.clone(), function_names) + }; + let (first_instructions, first_names) = compile(); + let (second_instructions, second_names) = compile(); + + assert_eq!( + first_names, second_names, + "function table must be identical across compilations" + ); + assert_eq!( + first_instructions, second_instructions, + "bytecode must be identical across compilations" + ); + + remove_module_root(&root); +} + +#[test] +fn import_order_swap_produces_identical_behavior() { + let root = temp_module_root("semantic_m6_import_order"); + write_source( + &root.join("a.rss"), + "pub fn value() -> int { 3 }\n", + "a source", + ); + write_source( + &root.join("b.rss"), + "pub fn value() -> int { 4 }\n", + "b source", + ); + let main_ab = root.join("main_ab.rss"); + write_source( + &main_ab, + "use self::a as a;\nuse self::b as b;\na::value();\nb::value();\n", + "main ab source", + ); + let main_ba = root.join("main_ba.rss"); + write_source( + &main_ba, + "use self::b as b;\nuse self::a as a;\nb::value();\na::value();\n", + "main ba source", + ); + + let run = |path: &Path| -> Vec { + let compiled = compile_source_file(path).expect("compile should succeed"); + let mut vm = Vm::new(compiled.program); + assert_eq!(vm.run().expect("vm should run"), VmStatus::Halted); + vm.stack().to_vec() + }; + assert_eq!(run(&main_ab), vec![Value::Int(3), Value::Int(4)]); + assert_eq!( + run(&main_ba), + vec![Value::Int(4), Value::Int(3)], + "import order must not change which module each call resolves to" + ); + + remove_module_root(&root); +} + +#[test] +fn host_namespace_imports_stay_on_the_host_path_without_rewriting() { + // A virtual host namespace import must compile to host imports even + // though its single-segment form parses like a file-module candidate. + let root = temp_module_root("semantic_m6_host_path"); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use myhost;\nmyhost::do_thing(81);\n", + "main source", + ); + + let compiled = compile_source_file(&main_path).expect("host namespace import should compile"); + let host_names = compiled + .functions + .iter() + .map(|func| func.name.as_str()) + .collect::>(); + assert!( + host_names.contains(&"myhost::do_thing"), + "host namespace call must remain a host import: {host_names:?}" + ); + + remove_module_root(&root); +} + +#[test] +fn namespace_member_arity_mismatch_is_a_diagnostic() { + let root = temp_module_root("semantic_m6_arity_mismatch"); + write_source( + &root.join("util.rss"), + "pub fn add(x, y) { x + y; }\n", + "util source", + ); + let main_path = root.join("main.rss"); + write_source( + &main_path, + "use self::util as u;\nu::add(1);\n", + "main source", + ); + + let err = match compile_source_file(&main_path) { + Ok(_) => panic!("arity mismatch must fail"), + Err(err) => err, + }; + let message = err.to_string(); + assert!( + message.contains("function 'u::add' expects 2 arguments"), + "unexpected diagnostic: {message}" + ); + + remove_module_root(&root); +} diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index b95d10fa..d8ce5df8 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1,5 +1,9 @@ #![allow(clippy::duplicate_mod)] +#[cfg(feature = "async")] +#[path = "support/async_test_bridge.rs"] +mod async_test_bridge; + #[cfg(feature = "runtime")] #[path = "compiler/compiler_common_tests.rs"] mod compiler_common_tests; @@ -26,3 +30,23 @@ mod type_inference_tests; #[cfg(feature = "runtime")] #[path = "compiler/whitespace_resilience_tests.rs"] mod whitespace_resilience_tests; + +#[cfg(feature = "runtime")] +#[path = "compiler/semantic_module_m12_tests.rs"] +mod semantic_module_m12_tests; + +#[cfg(feature = "runtime")] +#[path = "compiler/semantic_module_m3_tests.rs"] +mod semantic_module_m3_tests; + +#[cfg(feature = "runtime")] +#[path = "compiler/semantic_module_m4_tests.rs"] +mod semantic_module_m4_tests; + +#[cfg(feature = "runtime")] +#[path = "compiler/semantic_module_m5_tests.rs"] +mod semantic_module_m5_tests; + +#[cfg(feature = "runtime")] +#[path = "compiler/semantic_module_m6_tests.rs"] +mod semantic_module_m6_tests; diff --git a/tests/fixtures/modules/frame_local_dispatch/chain_0.rss b/tests/fixtures/modules/frame_local_dispatch/chain_0.rss new file mode 100644 index 00000000..a483c457 --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/chain_0.rss @@ -0,0 +1,79 @@ +pub fn h_0(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_0(a: int, b: int) -> int { + let t = a + b; + h_0(t, a); +} + +pub fn h_1(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_1(a: int, b: int) -> int { + let t = a + b; + h_1(t, a); +} + +pub fn h_2(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_2(a: int, b: int) -> int { + let t = a + b; + h_2(t, a); +} + +pub fn h_3(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_3(a: int, b: int) -> int { + let t = a + b; + h_3(t, a); +} + +pub fn h_4(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_4(a: int, b: int) -> int { + let t = a + b; + h_4(t, a); +} + +pub fn h_5(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_5(a: int, b: int) -> int { + let t = a + b; + h_5(t, a); +} + +pub fn h_6(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_6(a: int, b: int) -> int { + let t = a + b; + h_6(t, a); +} + +pub fn h_7(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_7(a: int, b: int) -> int { + let t = a + b; + h_7(t, a); +} diff --git a/tests/fixtures/modules/frame_local_dispatch/chain_1.rss b/tests/fixtures/modules/frame_local_dispatch/chain_1.rss new file mode 100644 index 00000000..353ba6c6 --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/chain_1.rss @@ -0,0 +1,79 @@ +pub fn h_8(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_8(a: int, b: int) -> int { + let t = a + b; + h_8(t, a); +} + +pub fn h_9(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_9(a: int, b: int) -> int { + let t = a + b; + h_9(t, a); +} + +pub fn h_10(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_10(a: int, b: int) -> int { + let t = a + b; + h_10(t, a); +} + +pub fn h_11(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_11(a: int, b: int) -> int { + let t = a + b; + h_11(t, a); +} + +pub fn h_12(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_12(a: int, b: int) -> int { + let t = a + b; + h_12(t, a); +} + +pub fn h_13(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_13(a: int, b: int) -> int { + let t = a + b; + h_13(t, a); +} + +pub fn h_14(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_14(a: int, b: int) -> int { + let t = a + b; + h_14(t, a); +} + +pub fn h_15(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_15(a: int, b: int) -> int { + let t = a + b; + h_15(t, a); +} diff --git a/tests/fixtures/modules/frame_local_dispatch/chain_2.rss b/tests/fixtures/modules/frame_local_dispatch/chain_2.rss new file mode 100644 index 00000000..b2552fcb --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/chain_2.rss @@ -0,0 +1,79 @@ +pub fn h_16(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_16(a: int, b: int) -> int { + let t = a + b; + h_16(t, a); +} + +pub fn h_17(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_17(a: int, b: int) -> int { + let t = a + b; + h_17(t, a); +} + +pub fn h_18(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_18(a: int, b: int) -> int { + let t = a + b; + h_18(t, a); +} + +pub fn h_19(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_19(a: int, b: int) -> int { + let t = a + b; + h_19(t, a); +} + +pub fn h_20(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_20(a: int, b: int) -> int { + let t = a + b; + h_20(t, a); +} + +pub fn h_21(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_21(a: int, b: int) -> int { + let t = a + b; + h_21(t, a); +} + +pub fn h_22(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_22(a: int, b: int) -> int { + let t = a + b; + h_22(t, a); +} + +pub fn h_23(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_23(a: int, b: int) -> int { + let t = a + b; + h_23(t, a); +} diff --git a/tests/fixtures/modules/frame_local_dispatch/chain_3.rss b/tests/fixtures/modules/frame_local_dispatch/chain_3.rss new file mode 100644 index 00000000..383b9268 --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/chain_3.rss @@ -0,0 +1,79 @@ +pub fn h_24(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_24(a: int, b: int) -> int { + let t = a + b; + h_24(t, a); +} + +pub fn h_25(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_25(a: int, b: int) -> int { + let t = a + b; + h_25(t, a); +} + +pub fn h_26(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_26(a: int, b: int) -> int { + let t = a + b; + h_26(t, a); +} + +pub fn h_27(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_27(a: int, b: int) -> int { + let t = a + b; + h_27(t, a); +} + +pub fn h_28(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_28(a: int, b: int) -> int { + let t = a + b; + h_28(t, a); +} + +pub fn h_29(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_29(a: int, b: int) -> int { + let t = a + b; + h_29(t, a); +} + +pub fn h_30(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_30(a: int, b: int) -> int { + let t = a + b; + h_30(t, a); +} + +pub fn h_31(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_31(a: int, b: int) -> int { + let t = a + b; + h_31(t, a); +} diff --git a/tests/fixtures/modules/frame_local_dispatch/chain_4.rss b/tests/fixtures/modules/frame_local_dispatch/chain_4.rss new file mode 100644 index 00000000..5d735b12 --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/chain_4.rss @@ -0,0 +1,64 @@ +pub fn f_32(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_33(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_34(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_35(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_36(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_37(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_38(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_39(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_40(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_41(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_42(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_43(a: int, b: int) -> int { + let t = a + b; + t; +} + +pub fn f_44(a: int, b: int) -> int { + let t = a + b; + t; +} diff --git a/tests/fixtures/modules/frame_local_dispatch/main.rss b/tests/fixtures/modules/frame_local_dispatch/main.rss new file mode 100644 index 00000000..47556047 --- /dev/null +++ b/tests/fixtures/modules/frame_local_dispatch/main.rss @@ -0,0 +1,45 @@ +use self::chain_0 as chain_0; +use self::chain_1 as chain_1; +use self::chain_2 as chain_2; +use self::chain_3 as chain_3; +use self::chain_4 as chain_4; + +fn dispatch(idx) { + let mut acc = 0; + if idx == 0 { acc = chain_0::f_0(acc, 1); } + else if idx == 1 { acc = chain_0::f_1(acc, 2); } + else if idx == 2 { acc = chain_0::f_2(acc, 3); } + else if idx == 3 { acc = chain_0::f_3(acc, 4); } + else if idx == 4 { acc = chain_0::f_4(acc, 5); } + else if idx == 5 { acc = chain_0::f_5(acc, 6); } + else if idx == 6 { acc = chain_0::f_6(acc, 7); } + else if idx == 7 { acc = chain_0::f_7(acc, 8); } + else if idx == 8 { acc = chain_1::f_8(acc, 9); } + else if idx == 9 { acc = chain_1::f_9(acc, 10); } + else if idx == 10 { acc = chain_1::f_10(acc, 11); } + else if idx == 11 { acc = chain_1::f_11(acc, 12); } + else if idx == 12 { acc = chain_1::f_12(acc, 13); } + else if idx == 13 { acc = chain_1::f_13(acc, 14); } + else if idx == 14 { acc = chain_1::f_14(acc, 15); } + else if idx == 15 { acc = chain_1::f_15(acc, 16); } + else if idx == 16 { acc = chain_2::f_16(acc, 17); } + else if idx == 17 { acc = chain_2::f_17(acc, 18); } + else if idx == 18 { acc = chain_2::f_18(acc, 19); } + else if idx == 19 { acc = chain_2::f_19(acc, 20); } + else if idx == 20 { acc = chain_2::f_20(acc, 21); } + else if idx == 21 { acc = chain_2::f_21(acc, 22); } + else if idx == 22 { acc = chain_2::f_22(acc, 23); } + else if idx == 23 { acc = chain_2::f_23(acc, 24); } + else if idx == 24 { acc = chain_3::f_24(acc, 25); } + else if idx == 25 { acc = chain_3::f_25(acc, 26); } + else if idx == 26 { acc = chain_3::f_26(acc, 27); } + else if idx == 27 { acc = chain_3::f_27(acc, 28); } + else if idx == 28 { acc = chain_3::f_28(acc, 29); } + else if idx == 29 { acc = chain_3::f_29(acc, 30); } + else if idx == 30 { acc = chain_3::f_30(acc, 31); } + else if idx == 31 { acc = chain_3::f_31(acc, 32); } + else { acc = chain_4::f_32(acc, 33); } + acc; +} +dispatch(0); +dispatch(31); diff --git a/tests/host_binding_generation_tests.rs b/tests/host_binding_generation_tests.rs index e465a9dc..2ce73ef4 100644 --- a/tests/host_binding_generation_tests.rs +++ b/tests/host_binding_generation_tests.rs @@ -6,7 +6,10 @@ use build_script::{ HostBindingKind, HostExecutionKind, classify_host_binding, infer_host_execution, }; use syn::parse_quote; -use vm::{HostFunctionRegistry, JitConfig, JitTraceTerminal, Value, Vm, VmStatus, compile_source}; +use vm::{ + BuiltinFunction, CapabilityProfile, HostFunctionRegistry, JitConfig, JitTraceTerminal, Value, + Vm, VmStatus, compile_source, +}; fn native_jit_supported() -> bool { (cfg!(target_arch = "x86_64") @@ -136,6 +139,18 @@ fn infers_host_suspension_from_the_return_signature() { ); } + let asynchronous = parse_quote!( + async fn host(value: String) -> VmResult {} + ); + assert_eq!( + infer_host_execution(&asynchronous), + HostExecutionKind::MaySuspend + ); + assert_eq!( + classify_host_binding(&asynchronous), + HostBindingKind::StaticStack + ); + let synchronous = parse_quote!( fn host() -> VmResult {} ); @@ -201,6 +216,48 @@ fn runtime_sleep_default_bindings_remain_inside_jit_loop_traces() { assert_runtime_sleep_loop_uses_native_host_call(true); } +#[test] +fn restricted_capabilities_disable_trace_jit_for_host_imports_and_builtins() { + for source in [ + r#" + use runtime; + let mut i = 0; + while i < 4 { + let _ = runtime::sleep(0); + i = i + 1; + } + i; + "#, + r#" + use re; + let mut i = 0; + while i < 4 { + let _ = re::match("a", "a"); + i = i + 1; + } + i; + "#, + ] { + let compiled = compile_source(source).expect("restricted loop should compile"); + let mut vm = Vm::new(compiled.program); + vm.set_jit_config(JitConfig { + enabled: native_jit_supported(), + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let error = HostFunctionRegistry::restricted() + .bind_vm_cached(&mut vm) + .expect_err("restricted registry should reject ungranted capability during preflight"); + + assert!( + error + .to_string() + .contains("capability profile does not allow") + ); + assert_eq!(vm.jit_native_exec_count(), 0); + } +} + #[test] fn runtime_exit_still_halts_for_direct_and_cached_default_bindings() { for bind_cached_registry in [false, true] { @@ -226,3 +283,60 @@ fn runtime_exit_still_halts_for_direct_and_cached_default_bindings() { assert!(vm.stack().is_empty()); } } + +#[test] +fn capability_profile_fingerprint_uses_stable_callable_identities() { + let first = CapabilityProfile::builder() + .allow_builtin(BuiltinFunction::JsonEncode) + .allow_host_import("custom::echo") + .build(); + let reordered = CapabilityProfile::builder() + .allow_host_import("custom::echo") + .allow_builtin(BuiltinFunction::JsonEncode) + .build(); + + assert_eq!(first, reordered); + assert_eq!(first.fingerprint(), reordered.fingerprint()); + assert!(first.allows_builtin(BuiltinFunction::JsonEncode)); + assert!(first.allows_host_import("custom::echo")); + assert!(!first.allows_host_import("custom::other")); + assert_ne!( + first.fingerprint(), + CapabilityProfile::deny_all().fingerprint() + ); + assert_ne!( + CapabilityProfile::allow_all().fingerprint(), + CapabilityProfile::deny_all().fingerprint() + ); +} + +#[test] +fn vm_host_core_does_not_name_builtin_subsystem_policies() { + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let host_runtime = std::fs::read_to_string(manifest.join("src/vm/host_runtime.rs")) + .expect("host runtime source"); + let capability = + std::fs::read_to_string(manifest.join("src/vm/capability.rs")).expect("capability source"); + let host = std::fs::read_to_string(manifest.join("src/vm/host.rs")).expect("host source"); + + for forbidden in [ + "HttpState", + "IoPolicy", + "SqlitePolicy", + "http_state", + "io_policy", + "sqlite_policy", + ] { + assert!( + !host_runtime.contains(forbidden), + "HostRuntime leaked {forbidden}" + ); + assert!( + !capability.contains(forbidden), + "CapabilityProfile leaked {forbidden}" + ); + } + for forbidden in ["configure_http", "configure_sqlite", "http_is_configured"] { + assert!(!host.contains(forbidden), "Vm API leaked {forbidden}"); + } +} diff --git a/tests/invocation_stream_tests.rs b/tests/invocation_stream_tests.rs new file mode 100644 index 00000000..f5ea5a29 --- /dev/null +++ b/tests/invocation_stream_tests.rs @@ -0,0 +1,879 @@ +#![cfg(feature = "runtime")] + +//! Invocation item stream contract tests. +//! +//! An invocation behaves like `Stream>`: +//! zero or more `Event` items, then exactly one `Complete` item or one typed error, +//! then a fused end of stream. Input enters through ordinary callable arguments and +//! polling drives execution (backpressure). + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use vm::{ + CancellationReason, HostFunctionRegistry, InvocationError, InvocationItem, InvocationPoll, + Value, Vm, VmError, compile_source, +}; + +/// Compiles a source, binds the default runtime host registry, and completes the +/// root frame so exported callables can be started. +fn compiled_vm(source: &str) -> Vm { + let program = compile_source(source) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default runtime host registry should bind"); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + vm +} + +/// Drives one exported `run` callable to the end of its invocation stream. +fn collect_items(vm: &mut Vm, args: Vec) -> Vec> { + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, args) + .expect("invocation should start"); + let mut items = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + assert!( + Instant::now() < deadline, + "invocation drive loop must terminate" + ); + match invocation + .poll_next() + .expect("invocation poll should not fail") + { + InvocationPoll::Ready(Some(item)) => items.push(item), + InvocationPoll::Ready(None) => break, + InvocationPoll::Pending => std::thread::sleep(Duration::from_millis(1)), + } + } + items +} + +#[test] +fn invocation_input_arrives_as_ordinary_callable_arguments() { + let mut vm = compiled_vm( + r#" + pub fn run(input: map) -> map { + input; + } + "#, + ); + let input = Value::map(vec![(Value::string("kind"), Value::string("message"))]); + let items = collect_items(&mut vm, vec![input.clone()]); + assert_eq!(items.len(), 1, "expected exactly one stream item"); + assert!( + matches!(&items[0], Ok(InvocationItem::Complete(value)) if *value == input), + "the exact structured argument must be the callable input, got {:?}", + items + ); +} + +#[test] +fn invocation_without_events_yields_complete_then_fused_end() { + let mut vm = compiled_vm( + r#" + pub fn run() -> int { + 42; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable.clone(), vec![]) + .expect("invocation should start"); + + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); + assert!( + matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + ), + "the stream must stay fused after Complete" + ); + + // Once the first invocation has fused, a new invocation may start on the + // same VM. + let mut second = vm + .start_invocation(callable, vec![]) + .expect("a new invocation may start after fusion"); + assert!(matches!( + second.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) + )); + assert!(matches!( + second.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_starting_a_second_invocation_while_one_is_active_is_rejected() { + let mut vm = compiled_vm( + r#" + pub fn run() -> int { + 42; + } + "#, + ); + let callable = vm + .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. + 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 + .start_invocation(callable, vec![]) + .expect_err("a second active invocation must be rejected"); + assert!( + matches!(rejected, VmError::InvalidFrameState(_)), + "unexpected rejection error: {rejected:?}" + ); +} + +#[test] +fn invocation_failures_are_typed_items_without_stack_or_string_inspection() { + let mut vm = compiled_vm( + r#" + pub fn run(input: int) -> int { + 100 / input; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![Value::Int(0)]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Vm(VmError::DivisionByZero)))) => {} + other => panic!("expected a typed division-by-zero item, got {other:?}"), + } + assert!( + matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + ), + "the stream must fuse after the error item" + ); +} + +/// Records one script-visible progress note per call. +struct ProgressNote(Arc>>); + +impl vm::HostArgsFunction for ProgressNote { + fn call(&mut self, args: &[Value]) -> vm::VmResult { + if let Some(value) = args.first() { + self.0 + .lock() + .expect("progress note lock should not be poisoned") + .push(value.clone()); + } + Ok(vm::CallOutcome::Return(vm::CallReturn::one( + args.first().cloned().unwrap_or(Value::Null), + ))) + } +} + +#[test] +fn invocation_emits_events_then_complete_in_order() { + let mut vm = compiled_vm( + r#" + use stream; + pub fn run() -> string { + stream::emit("first"); + stream::emit("second"); + "done"; + } + "#, + ); + let items = collect_items(&mut vm, vec![]); + assert_eq!( + items.len(), + 3, + "expected event, event, complete; got {items:?}" + ); + assert!( + matches!(&items[0], Ok(InvocationItem::Event(value)) if *value == Value::string("first")) + ); + assert!( + matches!(&items[1], Ok(InvocationItem::Event(value)) if *value == Value::string("second")) + ); + assert!( + matches!(&items[2], Ok(InvocationItem::Complete(value)) if *value == Value::string("done")) + ); +} + +#[test] +fn invocation_event_values_never_replace_the_callable_return_value() { + let mut vm = compiled_vm( + r#" + use stream; + pub fn run() -> int { + stream::emit("payload"); + 42; + } + "#, + ); + let items = collect_items(&mut vm, vec![]); + assert_eq!( + items.len(), + 2, + "expected event then complete; got {items:?}" + ); + assert!( + matches!(&items[0], Ok(InvocationItem::Event(value)) if *value == Value::string("payload")) + ); + assert!(matches!( + &items[1], + Ok(InvocationItem::Complete(Value::Int(42))) + )); +} + +#[test] +fn invocation_polling_pauses_execution_and_exposes_one_event_at_a_time() { + let program = compile_source( + r#" + use stream; + fn note_progress(value: string) -> string; + pub fn run() -> string { + stream::emit("a"); + note_progress("after-a"); + stream::emit("b"); + note_progress("after-b"); + "done"; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + let notes = Arc::new(Mutex::new(Vec::::new())); + vm.bind_args_function("note_progress", Box::new(ProgressNote(Arc::clone(¬es)))); + // `stream::emit` binds lazily through the default host fallback; the custom + // host binding is not part of the registry plan. + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + // First poll: the script paused at the first emit; nothing after it ran. + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("a") + )); + assert!( + notes.lock().expect("notes lock").is_empty(), + "execution must not advance while polling is paused" + ); + + // Second poll: resume past emit(a), run note_progress("after-a"), pause at + // emit(b). Exactly one progress note may exist. + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("b") + )); + assert_eq!( + notes.lock().expect("notes lock").len(), + 1, + "exactly one progress note between polls" + ); + + // Third poll: resume past emit(b), run note_progress("after-b"), complete. + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(value)))) if value == Value::string("done") + )); + assert_eq!(notes.lock().expect("notes lock").len(), 2); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_cancellation_produces_one_typed_error_item_then_fused_end() { + let mut vm = compiled_vm( + r#" + use stream; + pub fn run() -> string { + stream::emit("before"); + while true { + 1; + } + "unreachable"; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("before") + )); + + invocation + .cancel(CancellationReason::Requested) + .expect("cancellation should be accepted"); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + CancellationReason::Requested, + )))) => {} + other => panic!("expected a typed cancellation item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_fuel_exhaustion_produces_one_typed_error_item() { + let mut vm = compiled_vm( + r#" + pub fn run() -> int { + while true { + 1; + } + 42; + } + "#, + ); + vm.set_fuel(8); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::OutOfFuel { + needed: _, + remaining: 0, + }))) => {} + other => panic!("expected a typed out-of-fuel item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_deadline_expiry_produces_one_typed_error_item() { + let mut vm = compiled_vm( + r#" + pub fn run() -> int { + 42; + } + "#, + ); + vm.set_epoch_deadline(0) + .expect("epoch deadline should be configured"); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::DeadlineReached { + current: 0, + deadline: 0, + }))) => {} + other => panic!("expected a typed deadline item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_host_failure_produces_one_typed_error_item() { + let program = compile_source( + r#" + fn fail_host() -> int; + pub fn run() -> int { + fail_host(); + 42; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_stack_function("fail_host", Box::new(FailingHost)); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Host { message }))) => { + assert_eq!(message, "boom"); + } + other => panic!("expected a typed host failure item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_event_bound_violations_are_typed_capability_errors() { + let mut vm = compiled_vm( + r#" + use stream; + pub fn run(input: string) -> int { + stream::emit(input); + 42; + } + "#, + ); + let oversized = "x".repeat(70 * 1024); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![Value::string(oversized)]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Capability(error)))) => { + assert_eq!(error.code(), vm::RuntimeErrorCode::EventPayloadTooLarge); + } + other => panic!("expected a typed capability error item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +/// Fails every host call with a plain embedding error. +struct FailingHost; + +impl vm::HostStackFunction for FailingHost { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + Err(vm::VmError::HostError("boom".to_string())) + } +} + +#[cfg(feature = "async")] +#[path = "support/async_test_bridge.rs"] +mod async_test_bridge; + +/// Waits asynchronously through the embedding-owned host bridge. +#[cfg(feature = "async")] +struct AsyncWaitHost; + +#[cfg(feature = "async")] +impl vm::HostStackFunction for AsyncWaitHost { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + vm.submit_host_future(Box::pin(async move { + tokio::time::sleep(Duration::from_millis(20)).await; + Ok(vm::HostFutureOutput::returning(vm::CallReturn::one( + Value::Int(7), + ))) + })) + } +} + +#[cfg(feature = "async")] +#[test] +fn invocation_waiting_host_operation_returns_pending_and_preserves_item_order() { + let program = compile_source( + r#" + use stream; + fn wait_host() -> int; + pub fn run() -> string { + stream::emit("a"); + wait_host(); + stream::emit("b"); + "done"; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_stack_function("wait_host", Box::new(AsyncWaitHost)); + async_test_bridge::install(&mut vm); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("a") + )); + + // The outstanding host operation maps to Pending; drive it and poll again. + let deadline = Instant::now() + Duration::from_secs(10); + let mut polled_pending = false; + let next = loop { + assert!( + Instant::now() < deadline, + "waiting invocation must resume through the host driver" + ); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Pending => { + polled_pending = true; + std::thread::sleep(Duration::from_millis(1)); + } + ready => break ready, + } + }; + assert!( + polled_pending, + "the waiting host op must surface as Pending" + ); + assert!(matches!( + next, + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("b") + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(value)))) if value == Value::string("done") + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_cancellation_is_consumed_at_the_invocation_boundary() { + // Regression: after a cancelled invocation emits its typed error and + // fuses, the VM-level cancellation reason must not leak into a later + // invocation started on the same VM. + let mut vm = compiled_vm( + r#" + use stream; + pub fn run() -> string { + stream::emit("before"); + while true { + 1; + } + "unreachable"; + } + pub fn plain() -> int { + 42; + } + "#, + ); + let cancellable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(cancellable, vec![]) + .expect("invocation should start"); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("before") + )); + + invocation + .cancel(CancellationReason::Requested) + .expect("cancellation should be accepted"); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + CancellationReason::Requested, + )))) => {} + other => panic!("expected a typed cancellation item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); + + // A fresh invocation on the same VM must not inherit the old reason: it + // runs to completion instead of being cancelled on arrival. + let plain = vm + .resolve_exported_callable("plain") + .expect("exported plain callable should resolve"); + let mut second = vm + .start_invocation(plain, vec![]) + .expect("a new invocation may start after fusion"); + match second.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(Value::Int(42))))) => {} + other => panic!("the second invocation must complete normally, got {other:?}"), + } + assert!(matches!( + second.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_cancel_during_event_pending_discards_the_pending_event() { + // Cancellation is authoritative: a pending event that was placed but not + // yet delivered must be discarded (through the drop-contract path) and + // the stream must produce exactly one Cancelled item, then a fused end. + let program = compile_source( + r#" + use stream; + pub fn run() -> string { + stream::emit({"a": 1, "b": 2}); + while true { + 1; + } + "unreachable"; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + vm.set_drop_contract_events_enabled(true); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default runtime host registry should bind"); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let drops_before_cancel = vm.drop_contract_event_count(); + // `start_callable` runs to the first `stream::emit` yield, so the + // invocation is already in EventPending with the map payload. + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + invocation + .cancel(CancellationReason::Requested) + .expect("cancellation should be accepted"); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + CancellationReason::Requested, + )))) => {} + other => panic!("cancellation must supersede the pending event, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); + + // The discarded event payload (map plus its two key/value pairs) must be + // dropped through the VM drop-contract path, not leaked. + assert!( + vm.drop_contract_event_count() >= drops_before_cancel + 5, + "the discarded pending event payload must be dropped through the drop contract path" + ); +} + +#[test] +fn invocation_cancel_during_complete_pending_discards_the_pending_complete() { + // Cancellation is authoritative over a not-yet-delivered Complete item: + // the callable result is discarded and the stream produces exactly one + // Cancelled item, then a fused end. + let mut vm = compiled_vm( + r#" + pub fn run() -> map { + {"a": 1, "b": 2}; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + // The callable completes during `start_callable`, so the invocation is + // already in CompletePending with the return map. + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + invocation + .cancel(CancellationReason::Deadline) + .expect("cancellation should be accepted"); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + CancellationReason::Deadline, + )))) => {} + other => panic!("cancellation must supersede the pending complete, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +/// Fails asynchronously on the first poll of its submitted host operation. +#[cfg(feature = "async")] +struct AsyncFailHost; + +#[cfg(feature = "async")] +impl vm::HostStackFunction for AsyncFailHost { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> vm::VmResult { + vm.submit_host_future(Box::pin(async move { + Err(vm::VmError::HostError("bridge future failed".to_string())) + })) + } +} + +#[cfg(feature = "async")] +#[test] +fn invocation_host_op_first_poll_failure_keeps_typed_capability_error() { + // Regression: the waiting operation id must be captured after `run()` + // registers the host op. If the first poll fails and clears the waiting + // state, `map_invocation_error` must still recover the structured + // `OperationStatus::Failed` error instead of flattening it to a string. + let program = compile_source( + r#" + fn fail_host() -> int; + pub fn run() -> int { + fail_host(); + 42; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_stack_function("fail_host", Box::new(AsyncFailHost)); + async_test_bridge::install(&mut vm); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Capability(error)))) => { + assert_eq!(error.code(), vm::RuntimeErrorCode::OperationFailed); + assert_eq!(error.operation(), "runtime::host_bridge"); + assert!( + error.value().is_some(), + "the typed failure must carry the operation id" + ); + } + other => panic!( + "expected a typed capability error for the first-poll host op failure, got {other:?}" + ), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[cfg(feature = "async")] +#[test] +fn invocation_cancellation_while_waiting_produces_one_typed_error_item() { + let program = compile_source( + r#" + use stream; + fn wait_host() -> int; + pub fn run() -> string { + stream::emit("a"); + wait_host(); + "unreachable"; + } + "#, + ) + .expect("invocation source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_stack_function("wait_host", Box::new(AsyncWaitHost)); + async_test_bridge::install(&mut vm); + assert_eq!( + vm.run().expect("root frame should halt"), + vm::VmStatus::Halted + ); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("a") + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Pending + )); + + invocation + .cancel(CancellationReason::Deadline) + .expect("cancellation should be accepted"); + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Err(InvocationError::Cancelled( + CancellationReason::Deadline, + )))) => {} + other => panic!("expected a typed cancellation item, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} diff --git a/tests/jit/jit_tests.rs b/tests/jit/jit_tests.rs index 2cca3dfb..284dc487 100644 --- a/tests/jit/jit_tests.rs +++ b/tests/jit/jit_tests.rs @@ -2503,6 +2503,8 @@ fn trace_jit_reports_exact_parent_exit_profiles() { let mut i = 0; let mut total = 0; + let f = choose; + while i < 64 { total = total + choose(i); i = i + 1; @@ -6069,6 +6071,8 @@ fn trace_jit_links_dynamic_concat_callable_graph() { out } let values: map = { "a": "one", "b": "two" }; + let f = encode_map; + let mut i = 0; let mut out = ""; while i < 8 { @@ -6574,6 +6578,8 @@ fn trace_jit_inlines_static_leaf_in_root_loop() { let source = r#" fn add_one(value: int) -> int { value + 1 } let mut i = 0; + let f = add_one; + while i < 100 { i = add_one(i); } @@ -6617,6 +6623,9 @@ fn trace_jit_guards_static_inline_callable_identity() { fn add_one(value: int) -> int { value + 1 } fn add_ten(value: int) -> int { value + 10 } let mut i = 0; + let f = add_one; + let g = add_ten; + let mut total = 0; while i < 100 { total = add_one(total); @@ -6662,6 +6671,9 @@ fn trace_jit_invalidates_native_inline_after_callable_local_replacement() { fn add_one(value: int) -> int { value + 1 } fn add_ten(value: int) -> int { value + 10 } let mut i = 0; + let f = add_one; + let g = add_ten; + let mut total = 0; while i < 100 { total = add_one(total); @@ -6842,6 +6854,7 @@ fn trace_jit_preserves_inline_callable_argument_schema_checks() { } let source = r#" fn ignore(value: int) -> int { 1 } + let f = ignore; let mut i = 0; let value: int = 7; while i < 100 { @@ -6887,6 +6900,14 @@ fn trace_jit_preserves_inline_callable_argument_schema_checks() { matches!(error, vm::VmError::TypeMismatch("callable argument schema")), "unexpected error: {error:?}" ); + // The call went through the CallValue boundary and was inlined by the + // trace JIT: the argument schema guard must have been exercised by the + // native trace rather than silently handled by the interpreter. + assert!( + any_trace_op(&vm.jit_snapshot(), "inline_call:0"), + "{}", + vm.dump_jit_info() + ); } #[test] @@ -6897,6 +6918,8 @@ fn trace_jit_inline_instruction_failure_restores_callee_frame() { let source = r#" fn get(values: [int], index: int) -> int { values[index] } let values: [int] = [10, 20]; + let f = get; + let mut i = 0; let mut sink = 0; while i < 100 { @@ -6936,6 +6959,8 @@ fn trace_jit_inline_unbox_failure_matches_interpreter_error() { let source = r#" fn add_one(values: [int]) -> int { values[0] + 1 } let values: [int] = [7]; + let f = add_one; + let mut i = 0; let mut sink = 0; while i < 100 { @@ -7006,6 +7031,8 @@ fn trace_jit_preserves_inline_callable_return_schema_checks() { let source = r#" fn first(values: [int]) -> int { values[0] } let values: [int] = [7]; + let f = first; + let mut i = 0; let mut sink = 0; while i < 100 { @@ -7084,6 +7111,8 @@ fn trace_jit_inlines_array_swap_leaf() { temporary } let values: [int] = [1, 2]; + let f = swap; + let mut i = 0; while i < 100 { i = i + swap(values, 0, 1) * 0 + 1; @@ -7133,6 +7162,8 @@ fn trace_jit_inline_array_set_failure_restores_callee_frame() { values[0] } let values: [int] = [10, 20]; + let f = write; + let mut i = 0; let mut sink = 0; while i < 100 { @@ -7185,6 +7216,8 @@ fn trace_jit_inline_guard_exit_restores_callee() { } let mut i = 0; let mut result = 0; + let f = classify; + while i < 4 { result = classify(i); i = i + 1; @@ -7225,6 +7258,7 @@ fn trace_jit_inline_guard_exit_restores_callee() { fn trace_jit_call_site_profiles_clear_on_vm_reuse() { let source = r#" fn add_one(value: int) -> int { value + 1 } + let f = add_one; let mut i = 0; while i < 3 { i = add_one(i); @@ -7241,6 +7275,10 @@ fn trace_jit_call_site_profiles_clear_on_vm_reuse() { assert_eq!(vm.run().expect("first profile run"), VmStatus::Halted); assert_eq!(vm.jit_snapshot().metrics.script_call_observations, 3); + assert!( + !vm.jit_call_site_profiles().is_empty(), + "call-site profiles must be recorded through the callable boundary" + ); vm.reset_for_reuse(); @@ -7362,6 +7400,7 @@ fn trace_jit_missing_dynamic_return_target_does_not_use_stale_static_slot() { } let source = r#" fn inc(x: int) -> int { x + 1 } + let f = inc; let mut i = 0; let mut value = 0; while i < 32 { @@ -7384,6 +7423,11 @@ fn trace_jit_missing_dynamic_return_target_does_not_use_stale_static_slot() { VmStatus::Halted ); assert_eq!(vm.stack(), &[Value::Int(133)]); + assert!( + vm.jit_native_exec_count() > 0, + "the loop must execute natively to exercise return-target resolution: {}", + vm.dump_jit_info() + ); } #[test] @@ -7396,6 +7440,8 @@ fn trace_jit_links_nested_dynamic_script_callables_without_interpreter_handoff() fn add_two(value: int) -> int { value + 2 } fn apply(function: fn(int) -> int, value: int) -> int { function(value) } let mut i = 0; + let f = apply; + let mut total = 0; while i < 16 { let selected = if i < 8 => { add_one } else => { add_two }; @@ -7445,6 +7491,9 @@ fn trace_jit_links_finite_mutual_recursion_without_interpreter_handoff() { if value == 0 => { 0 } else => { even(value - 1) } } let mut i = 0; + let f = even; + let g = odd; + let mut total = 0; while i < 8 { total = total + even(8); @@ -7472,3 +7521,1050 @@ fn trace_jit_links_finite_mutual_recursion_without_interpreter_handoff() { ); assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); } + +// --------------------------------------------------------------------------- +// Milestone 7: `CallScript` backend parity (Trace JIT and AOT). +// +// The interpreter contract is pinned in tests/vm/call_script_tests.rs; these +// tests prove the same operation executes through the native JIT boundary and +// the whole-program AOT pipeline without being reinterpreted as host `Call` +// or dynamic `CallValue`. + +/// Build a program whose root body is a hot loop that calls +/// `CallScript(prototype_id, argc)` each iteration; the callee body is raw +/// bytes. Used to prove typed failures surface through the native boundary. +fn call_script_loop_program( + prototype_id: u32, + argc: u8, + arity: u8, + target: vm::CallableTarget, + capture_slots: Vec, + self_slot: Option, + callee_body: Vec, +) -> Program { + // Root body: + // ldc 0; stloc 0 i = 0 + // loop: (backward branch target) + // ldloc 0; ldc 1; add; stloc 0 + // callscript(prototype_id, argc) + // ldloc 0; ldc 4; clt; brfalse end + // br loop + // end: ldc 0; ret + let mut code = vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 0]; + let loop_header = code.len() as u32; + code.extend_from_slice(&[ + OpCode::Ldloc as u8, + 0, + OpCode::Ldc as u8, + 1, + 0, + 0, + 0, + OpCode::Add as u8, + OpCode::Stloc as u8, + 0, + OpCode::CallScript as u8, + ]); + code.extend_from_slice(&prototype_id.to_le_bytes()); + code.push(argc); + code.extend_from_slice(&[ + OpCode::Ldloc as u8, + 0, + OpCode::Ldc as u8, + 2, + 0, + 0, + 0, + OpCode::Clt as u8, + OpCode::Brfalse as u8, + ]); + // The brfalse target must be the instruction immediately after `br loop` + // (a `Br` opcode at code.len()+4 plus its four-byte operand), not a byte + // inside the `br` instruction. + let end_ip = code.len() as u32 + 9; + code.extend_from_slice(&end_ip.to_le_bytes()); + code.extend_from_slice(&[OpCode::Br as u8]); + code.extend_from_slice(&loop_header.to_le_bytes()); + // end: ldc 0; ret + code.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Ret as u8]); + let function_entry = code.len() as u32; + code.extend_from_slice(&callee_body); + let function_end = code.len() as u32; + + Program::new(vec![Value::Int(0), Value::Int(1), Value::Int(4)], code) + .with_local_count(1) + .with_callable_metadata( + vec![vm::ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![vm::CallablePrototype { + kind: vm::CallableKind::FunctionItem, + target, + arity, + frame_local_count: 1, + parameter_slots: (0..arity).map(u16::from).collect(), + capture_source_slots: Vec::new(), + capture_slots, + capture_modes: Vec::new(), + self_slot, + schema: None, + }], + vec![ + vm::FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + vm::FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + vec![], + ) +} + +#[test] +fn call_script_direct_call_loop_runs_natively() { + if !native_jit_supported() { + return; + } + // The callee contains a loop so inline analysis must reject it + // (BackwardBranch), forcing a native `call_script` call boundary. + let source = r#" + fn bump(value: int) -> int { + let mut x = 0; + while x < 1 { + x = x + 1; + } + value + 1 + } + let mut i = 0; + let mut total = 0; + while i < 16 { + total = bump(total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("direct call loop should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + + assert_eq!( + vm.run().expect("direct call loop should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(16)]); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace.has_call + && trace.op_names().iter().any(|name| name == "call_script") + && trace.executions > 0), + "expected native call_script trace: {}", + vm.dump_jit_info() + ); + assert!( + !any_trace_op(&snapshot, "call_value"), + "CallScript must not be reinterpreted as CallValue: {}", + vm.dump_jit_info() + ); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); +} + +#[test] +fn call_script_nested_direct_calls_resume_continuation() { + if !native_jit_supported() { + return; + } + let source = r#" + fn add2(value: int) -> int { value + 2 } + fn add5(value: int) -> int { add2(value) + 3 } + let mut i = 0; + let mut total = 0; + while i < 8 { + total = add5(total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("nested direct calls should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + + assert_eq!( + vm.run().expect("nested direct call loop should run"), + VmStatus::Halted + ); + // The continuation after each call resumes inside the traced loop and the + // accumulator survives across native boundaries. + assert_eq!(vm.stack(), &[Value::Int(40)]); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace.has_call + && trace.op_names().iter().any(|name| name == "call_script") + && trace.executions > 0), + "expected nested call_script boundary trace: {}", + vm.dump_jit_info() + ); + assert!( + !any_trace_op(&snapshot, "call_value"), + "nested direct calls must not be reinterpreted as CallValue: {}", + vm.dump_jit_info() + ); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); +} + +#[test] +fn call_script_direct_recursion_inside_loop() { + if !native_jit_supported() { + return; + } + let source = r#" + fn fact(n: int) -> int { + if n <= 1 => { 1 } else => { n * fact(n - 1) } + } + let mut i = 0; + let mut total = 0; + while i < 4 { + total = total + fact(5); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("direct recursion should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + + assert_eq!( + vm.run().expect("direct recursion loop should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(480)]); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace.has_call + && trace.op_names().iter().any(|name| name == "call_script") + && trace.executions > 0), + "expected recursive call_script boundary trace: {}", + vm.dump_jit_info() + ); + assert!(vm.dump_jit_info().contains("interpreter fallbacks: 0")); +} + +#[test] +fn call_script_failure_exit_reports_typed_error() { + if !native_jit_supported() { + return; + } + // Unbounded direct recursion is not inlinable (the body contains a + // nested `CallScript`), so the depth-limit failure must surface through + // the native `call_script` boundary as the same typed VmError the + // interpreter produces. + let source = r#" + fn f() -> int { f() } + let mut i = 0; + let mut total = 0; + while i < 2 { + total = total + f(); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("failure program should compile"); + let mut plain = Vm::new(compiled.program.clone().with_local_count(compiled.locals)); + plain.set_jit_config(JitConfig { + enabled: false, + ..JitConfig::default() + }); + let plain_err = plain + .run() + .expect_err("interpreter recursion must hit the depth limit"); + + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let err = vm + .run() + .expect_err("recursion should fail through the native boundary"); + assert_eq!( + format!("{err:?}"), + format!("{plain_err:?}"), + "native failure must match the interpreter's typed error" + ); + assert!( + matches!(err, vm::VmError::CallStackOverflow { .. }), + "expected CallStackOverflow, got {err:?}" + ); + let snapshot = vm.jit_snapshot(); + assert!( + any_trace_op(&snapshot, "call_script"), + "expected the failure to flow through a recorded call_script trace: {}", + vm.dump_jit_info() + ); +} + +#[test] +fn call_script_capture_prototype_fails_typed() { + if !native_jit_supported() { + return; + } + // VMBC accepts a script prototype that *requires* captures (runtime + // concern); `CallScript` can never supply an environment, so every + // backend must fail with the interpreter's typed error. + let program = call_script_loop_program( + 0, + 0, + 0, + vm::CallableTarget::ScriptFunction(0), + vec![0], + None, + vec![ + OpCode::Ldc as u8, + 0, + 0, + 0, + 0, + OpCode::Pop as u8, + OpCode::Ret as u8, + ], + ); + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + + let err = vm + .run() + .expect_err("capture-requiring prototype should fail through CallScript"); + assert!( + matches!(err, vm::VmError::CallScriptRequiresEnvironment(0)), + "expected CallScriptRequiresEnvironment(0), got {err:?}" + ); + let snapshot = vm.jit_snapshot(); + assert!( + any_trace_op(&snapshot, "call_script"), + "expected the typed failure to flow through a recorded call_script trace: {}", + vm.dump_jit_info() + ); +} + +/// The raw `CallScript` loop fixture must contain well-formed control flow: +/// the loop-exit `brfalse` lands on the instruction after `br loop`, and the +/// root body terminates with `ldc 0; ret` after the loop. A fixture whose +/// branch target points into the middle of the `br` instruction would decode +/// callee bytes as root code and produce a different final stack. (The loop +/// deliberately leaves the callee results on the stack, so it is not +/// traceable; this pins the bytecode layout itself.) +#[test] +fn call_script_raw_fixture_loop_completes() { + let program = call_script_loop_program( + 0, + 0, + 0, + vm::CallableTarget::ScriptFunction(0), + vec![], + None, + vec![OpCode::Ldc as u8, 1, 0, 0, 0, OpCode::Ret as u8], + ); + let mut vm = Vm::new(program); + + assert_eq!( + vm.run().expect("the raw fixture loop should complete"), + VmStatus::Halted + ); + // Four iterations push the callee result (Int(1)); the root's `end:` + // block then pushes Int(0) and returns. + assert_eq!( + vm.stack(), + &[ + Value::Int(1), + Value::Int(1), + Value::Int(1), + Value::Int(1), + Value::Int(0) + ] + ); +} + +#[test] +fn call_script_fuel_interruption_matches_interpreter() { + if !native_jit_supported() { + return; + } + let source = r#" + fn bump(value: int) -> int { + let mut x = 0; + while x < 1 { + x = x + 1; + } + value + 1 + } + let mut i = 0; + let mut total = 0; + while i < 1000 { + total = bump(total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("fuel program should compile"); + + // Fuel interruption yields (VmStatus::Yielded with a Fuel reason); the + // interpreter and the JIT must both interrupt the direct-call loop the + // same way and then complete after recharging. + let drain = |vm: &mut Vm| { + loop { + match vm.run().expect("fuel-limited run should yield") { + VmStatus::Halted => break, + VmStatus::Yielded => { + assert_eq!(vm.last_yield_reason(), Some(VmYieldReason::Fuel)); + vm.recharge_fuel(200).expect("fuel recharge should succeed"); + } + VmStatus::Waiting(_) => panic!("unexpected host wait"), + } + } + assert_eq!(vm.stack(), &[Value::Int(1000)]); + }; + + let mut plain = Vm::new(compiled.program.clone().with_local_count(compiled.locals)); + plain.set_jit_config(JitConfig { + enabled: false, + ..JitConfig::default() + }); + plain + .set_fuel_check_interval(1) + .expect("fuel interval should set"); + plain.set_fuel(200); + drain(&mut plain); + + // JIT: the direct call crosses the native boundary each iteration; fuel + // must still interrupt execution with the same yield contract. + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + vm.set_fuel_check_interval(1) + .expect("fuel interval should set"); + vm.set_fuel(200); + drain(&mut vm); +} + +#[test] +fn aot_call_script_direct_call_loop() { + if !native_jit_supported() { + return; + } + let source = r#" + fn bump(value: int) -> int { value + 1 } + let mut i = 0; + let mut total = 0; + while i < 16 { + total = bump(total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("aot direct call loop should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + install_aot(&mut vm); + + let status = vm.run().expect("aot direct call loop should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(16)]); + assert!( + vm.aot_exec_count() > 0, + "aot should execute the direct call loop natively: {}", + vm.dump_aot_info() + ); + assert!( + !vm.dump_aot_info().contains("interpreter-boundary"), + "aot should lower the call script program, not fall back: {}", + vm.dump_aot_info() + ); +} + +#[test] +fn aot_call_script_recursion() { + if !native_jit_supported() { + return; + } + let source = r#" + fn fact(n: int) -> int { + if n <= 1 => { 1 } else => { n * fact(n - 1) } + } + fact(8); + "#; + let compiled = compile_source(source).expect("aot recursion should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + install_aot(&mut vm); + + let status = vm.run().expect("aot recursion should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(40_320)]); + assert!( + vm.aot_exec_count() > 0, + "aot should execute the recursive program natively: {}", + vm.dump_aot_info() + ); +} + +#[test] +fn aot_call_script_failure_exit() { + if !native_jit_supported() { + return; + } + // Unbounded direct recursion fails with the interpreter's typed depth + // error through the AOT `call_script` boundary (the interpreter raises + // it inside `execute_call_script` and the bridge relays it unchanged). + let source = r#" + fn f() -> int { f() } + let mut i = 0; + let mut total = 0; + while i < 2 { + total = total + f(); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("aot failure program should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + install_aot(&mut vm); + + let err = vm + .run() + .expect_err("recursion should fail through aot call script"); + assert!( + matches!(err, vm::VmError::CallStackOverflow { .. }), + "expected CallStackOverflow, got {err:?}" + ); +} + +#[test] +fn aot_call_script_epoch_interruption() { + if !native_jit_supported() { + return; + } + let source = r#" + fn bump(value: int) -> int { value + 1 } + let mut i = 0; + let mut total = 0; + while i < 1000 { + total = bump(total); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("aot epoch program should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + install_aot(&mut vm); + vm.set_epoch_check_interval(1) + .expect("epoch interval update should succeed"); + vm.set_epoch_deadline(0) + .expect("setting epoch deadline should succeed"); + + let first = vm.run().expect("first aot run should yield"); + assert_eq!(first, VmStatus::Yielded); + assert_eq!(vm.last_yield_reason(), Some(VmYieldReason::Epoch)); + + vm.clear_epoch_deadline(); + let halted = vm.run().expect("run should halt after clearing epoch"); + assert_eq!(halted, VmStatus::Halted); + assert_eq!(vm.stack().last(), Some(&Value::Int(1000))); +} +// Milestone 7 follow-up: JIT/interpreter parity for inlined `CallScript` +// callee frame initialization. +// +// The interpreter's `enter_script_frame` (1) freshly binds every root +// callable binding slot to an environment-free callable, (2) inherits every +// callable-valued caller local at the same slot index, and (3) rejects root +// bindings outside the callee frame with `InvalidFrameState`. The recorder's +// inline simulation must mirror all three so raw programs cannot diverge +// between the interpreter and the trace JIT. +// +/// Build a program whose root body is a hot loop that calls +/// `CallScript(1 /* probe */, 0)` and accumulates the probe's result into +/// local 3. `root_prefix` is emitted before the loop. The callee `probe` +/// reads local slot `read_slot`, returns 1 when `typeof(slot) == "callable"` +/// and 0 otherwise, through a single `Ret`. A root binding for prototype 0 +/// (`a`) lives at local slot 1. +fn call_script_probe_loop_program(root_prefix: Vec, read_slot: u8) -> Program { + // Default loop body: i = i + 1; acc += CallScript(probe). + let mut body = vec![ + OpCode::Ldloc as u8, + 0, + OpCode::Ldc as u8, + 1, + 0, + 0, + 0, + OpCode::Add as u8, + OpCode::Stloc as u8, + 0, + OpCode::CallScript as u8, + ]; + body.extend_from_slice(&1u32.to_le_bytes()); + body.push(0); // argc + body.extend_from_slice(&[ + OpCode::Ldloc as u8, + 3, + OpCode::Add as u8, + OpCode::Stloc as u8, + 3, + ]); + call_script_probe_loop_program_with_body(root_prefix, read_slot, &body, 4) +} + +/// Builds the probe-loop fixture with a caller-provided loop body and local +/// count. The shared loop tail (`i < 4; brfalse end; br loop`) follows +/// `loop_body`; `loop_body` must leave the operand stack empty. +fn call_script_probe_loop_program_with_body( + root_prefix: Vec, + read_slot: u8, + loop_body: &[u8], + local_count: usize, +) -> Program { + // constants: 0=Int(0) 1=Int(1) 2=Int(7) 3=String("callable") 4=Int(4) + // 5=Int(5) (used by the rebind prefix) + let mut code = root_prefix; + // loop header + let loop_header = code.len() as u32; + code.extend_from_slice(loop_body); + code.extend_from_slice(&[ + OpCode::Ldloc as u8, + 0, + OpCode::Ldc as u8, + 4, + 0, + 0, + 0, + OpCode::Clt as u8, + OpCode::Brfalse as u8, + ]); + // end label: after `br loop` (5 bytes after the brfalse operand). + let end_ip = code.len() as u32 + 9; + code.extend_from_slice(&end_ip.to_le_bytes()); + code.extend_from_slice(&[OpCode::Br as u8]); + code.extend_from_slice(&loop_header.to_le_bytes()); + // end: ldloc 3; ret + code.extend_from_slice(&[OpCode::Ldloc as u8, 3, OpCode::Ret as u8]); + // a: ldc 7; ret + let a_entry = code.len() as u32; + code.extend_from_slice(&[OpCode::Ldc as u8, 2, 0, 0, 0, OpCode::Ret as u8]); + // probe: ldloc read_slot; call typeof/1; ldc "callable"; ceq; + // brfalse zero; ldc 1; br done; zero: ldc 0; done: ret + let probe_entry = code.len() as u32; + code.extend_from_slice(&[OpCode::Ldloc as u8, read_slot]); + code.extend_from_slice(&[OpCode::Call as u8, 0xA0, 0xFF, 1]); + code.extend_from_slice(&[OpCode::Ldc as u8, 3, 0, 0, 0]); + code.extend_from_slice(&[OpCode::Ceq as u8, OpCode::Brfalse as u8]); + let zero = code.len() as u32 + 14; + code.extend_from_slice(&zero.to_le_bytes()); + code.extend_from_slice(&[OpCode::Ldc as u8, 1, 0, 0, 0, OpCode::Br as u8]); + let done = code.len() as u32 + 9; + code.extend_from_slice(&done.to_le_bytes()); + code.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Ret as u8]); + let probe_end = code.len() as u32; + + Program::new( + vec![ + Value::Int(0), + Value::Int(1), + Value::Int(7), + Value::String(std::sync::Arc::new("callable".to_string())), + Value::Int(4), + Value::Int(5), + ], + code, + ) + .with_local_count(local_count) + .with_callable_metadata( + vec![ + vm::ScriptFunction { + entry_ip: a_entry, + end_ip: probe_entry, + }, + vm::ScriptFunction { + entry_ip: probe_entry, + end_ip: probe_end, + }, + ], + vec![ + vm::CallablePrototype { + kind: vm::CallableKind::FunctionItem, + target: vm::CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 1, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }, + vm::CallablePrototype { + kind: vm::CallableKind::FunctionItem, + target: vm::CallableTarget::ScriptFunction(1), + arity: 0, + frame_local_count: 4, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }, + ], + vec![ + vm::FunctionRegion { + start_ip: 0, + end_ip: a_entry, + prototype_id: None, + }, + vm::FunctionRegion { + start_ip: a_entry, + end_ip: probe_entry, + prototype_id: Some(0), + }, + vm::FunctionRegion { + start_ip: probe_entry, + end_ip: probe_end, + prototype_id: Some(1), + }, + ], + vec![vm::RootCallableBinding { + local_slot: 1, + prototype_id: 0, + }], + ) +} + +fn run_call_script_probe_loop(program: Program, jit_enabled: bool) -> Result, String> { + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: jit_enabled, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + match vm.run() { + Ok(VmStatus::Halted) => Ok(vm.stack().to_vec()), + Ok(status) => Err(format!("unexpected status {status:?}")), + Err(err) => Err(format!("{err:?}")), + } +} + +/// The interpreter inherits every callable-valued caller local into the +/// callee frame at the same slot index. An inlined direct callee that reads +/// a non-binding callable local must see the same value the interpreter +/// would provide, not a null slot. +#[test] +fn call_script_inline_inherits_callable_local_from_caller() { + if !native_jit_supported() { + return; + } + // Root copies `a` (binding slot 1) into non-binding slot 2 before each + // `CallScript`; probe reads slot 2. + let mut prefix = vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 0]; + prefix.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 3]); + prefix.extend_from_slice(&[OpCode::Ldloc as u8, 1, OpCode::Stloc as u8, 2]); + let program = call_script_probe_loop_program(prefix, 2); + + let plain = run_call_script_probe_loop(program.clone(), false) + .expect("interpreter should run the probe loop"); + assert_eq!( + plain, + vec![Value::Int(4)], + "probe must see the inherited callable" + ); + + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let result = vm.run().expect("jit should run the probe loop"); + assert_eq!(result, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(4)], + "jit must mirror the interpreter" + ); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace + .op_names() + .iter() + .any(|name| name.starts_with("inline_call:"))), + "expected an inlined call_script trace: {}", + vm.dump_jit_info() + ); +} + +/// The interpreter freshly binds every root callable binding slot on frame +/// entry, so a caller-side reassignment of the slot must not leak into an +/// inlined callee. The recorder must mirror that reset instead of copying +/// the caller's current slot value. +#[test] +fn call_script_inline_refreshes_root_binding_slot() { + if !native_jit_supported() { + return; + } + // Root reassigns binding slot 1 to Int(5) before the loop; probe reads + // slot 1 and must still see `a`'s fresh callable, not Int(5). + let mut prefix = vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 0]; + prefix.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 3]); + prefix.extend_from_slice(&[OpCode::Ldc as u8, 5, 0, 0, 0, OpCode::Stloc as u8, 1]); + let program = call_script_probe_loop_program(prefix, 1); + + let plain = run_call_script_probe_loop(program.clone(), false) + .expect("interpreter should run the probe loop"); + assert_eq!( + plain, + vec![Value::Int(4)], + "probe must see the freshly bound callable" + ); + + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let result = vm.run().expect("jit should run the probe loop"); + assert_eq!(result, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(4)], + "jit must mirror the interpreter" + ); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace + .op_names() + .iter() + .any(|name| name.starts_with("inline_call:"))), + "expected an inlined call_script trace: {}", + vm.dump_jit_info() + ); +} + +fn run_call_script_guarded_probe_loop( + program: Program, + jit_enabled: bool, +) -> Result, String> { + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: jit_enabled, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + // Install a capture-free callable at slot 4 so the loop body reaches a + // `CallValue` terminal every iteration; the interpreter then runs the + // callee and the code after it, which rewrites the inherited slot. The + // probe prototype is used because its frame fits the root binding. + vm.set_local( + 4, + Value::Callable(Arc::new(vm::CallableValue { + prototype_id: 1, + kind: vm::CallableKind::FunctionItem, + env: None, + })), + ) + .map_err(|err| format!("{err:?}"))?; + match vm.run() { + Ok(VmStatus::Halted) => Ok(vm.stack().to_vec()), + Ok(status) => Err(format!("unexpected status {status:?}")), + Err(err) => Err(format!("{err:?}")), + } +} + +/// The interpreter inherits callable-valued caller locals at the same slot +/// index, and an inlined `CallScript` callee can fold on the inherited +/// value's observed type. Re-entry after an interpreter handoff must not +/// run the folded callee against a rewritten slot: the recorder records an +/// entry guard for inherited callable locals, and cache lookup falls back +/// to the interpreter when the slot no longer holds the recorded callable. +#[test] +fn call_script_inline_guards_inherited_callable_local() { + if !native_jit_supported() { + return; + } + // Root copies `a` (binding slot 1) into non-binding slot 2 before the + // loop. Each iteration: i = i + 1; acc += CallScript(probe); + // CallValue(slot 4); pop; slot2 = 5; if (i < 4) goto loop. The probe + // returns 1 while slot 2 holds a callable and 0 otherwise. The trace + // records the inlined probe (folded `typeof` on the inherited slot) and + // terminates at the `CallValue`; the interpreter then rewrites slot 2, + // so a re-entered trace without an entry guard would keep folding the + // stale callable on later iterations. + let mut prefix = vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 0]; + prefix.extend_from_slice(&[OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Stloc as u8, 3]); + prefix.extend_from_slice(&[OpCode::Ldloc as u8, 1, OpCode::Stloc as u8, 2]); + let mut body = vec![ + OpCode::Ldloc as u8, + 0, + OpCode::Ldc as u8, + 1, + 0, + 0, + 0, + OpCode::Add as u8, + OpCode::Stloc as u8, + 0, + OpCode::CallScript as u8, + ]; + body.extend_from_slice(&1u32.to_le_bytes()); + body.push(0); // argc + body.extend_from_slice(&[ + OpCode::Ldloc as u8, + 3, + OpCode::Add as u8, + OpCode::Stloc as u8, + 3, + OpCode::Ldloc as u8, + 4, + OpCode::CallValue as u8, + 0, // argc + OpCode::Pop as u8, + OpCode::Ldc as u8, + 5, + 0, + 0, + 0, + OpCode::Stloc as u8, + 2, + ]); + let program = call_script_probe_loop_program_with_body(prefix, 2, &body, 5); + + let plain = run_call_script_guarded_probe_loop(program.clone(), false) + .expect("interpreter should run the guarded probe loop"); + assert_eq!( + plain, + vec![Value::Int(1)], + "probe must see the rewritten slot from the second iteration" + ); + + let mut vm = Vm::new(program); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + vm.set_local( + 4, + Value::Callable(Arc::new(vm::CallableValue { + prototype_id: 1, + kind: vm::CallableKind::FunctionItem, + env: None, + })), + ) + .expect("install callable"); + let result = vm.run().expect("jit should run the guarded probe loop"); + assert_eq!(result, VmStatus::Halted); + assert_eq!( + vm.stack(), + &[Value::Int(1)], + "jit must mirror the interpreter when the inherited callable slot is rewritten" + ); + let snapshot = vm.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|trace| trace + .op_names() + .iter() + .any(|name| name.starts_with("inline_call:"))), + "expected an inlined call_script trace: {}", + vm.dump_jit_info() + ); +} + +/// The interpreter reports `DivisionByZero` when a division inside a +/// direct-only callee fails through `CallScript`. The trace JIT's non-inline +/// `idiv` trap path and the AOT lowering predate `CallScript`: they relay +/// the failure before materializing the VM stack (`StackUnderflow`) or as a +/// raw `JitNative` entry failure without a typed `VmError`. This is a +/// pre-existing backend defect, not a `CallScript` gap, so the JIT/AOT sides +/// are pinned as a known regression instead of being silently fixed here. +/// +/// Ignored so CI stays green; run manually after any backend division work — +/// the JIT/AOT assertions flip when the pre-existing defect is fixed. +#[test] +#[ignore = "pre-existing non-inline JIT/AOT division failure path; run manually after backend division work"] +fn call_script_division_failure_path_known_regression() { + let source = r#" + fn div(n: int) -> int { + let mut x = 0; + while x < 1 { + x = x + 1; + } + 100 / n + } + let mut i = 0; + let mut total = 0; + while i < 2 { + total = total + div(i); + i = i + 1; + } + total; + "#; + let compiled = compile_source(source).expect("division program should compile"); + + // Interpreter contract: the callee's division failure surfaces through + // the `CallScript` boundary as a typed VmError. + let mut plain = Vm::new(compiled.program.clone().with_local_count(compiled.locals)); + plain.set_jit_config(JitConfig { + enabled: false, + ..JitConfig::default() + }); + let plain_err = plain.run().expect_err("interpreter division must fail"); + assert!( + matches!(plain_err, vm::VmError::DivisionByZero), + "expected DivisionByZero, got {plain_err:?}" + ); + + // KNOWN PRE-EXISTING REGRESSION: the traced non-inline `idiv` trap path + // reports StackUnderflow because the VM stack is not materialized before + // the error is relayed. Not a `CallScript` defect. + let mut vm = Vm::new(compiled.program.clone().with_local_count(compiled.locals)); + vm.set_jit_config(JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let jit_err = vm.run().expect_err("jit division must fail"); + assert!( + matches!(jit_err, vm::VmError::StackUnderflow), + "pre-existing JIT division regression changed: {jit_err:?}" + ); + + // KNOWN PRE-EXISTING REGRESSION: the AOT entry relay reports a raw + // JitNative failure without a typed VmError. + let mut aot = Vm::new(compiled.program.with_local_count(compiled.locals)); + aot.compile_aot().expect("aot compile should succeed"); + let aot_err = aot.run().expect_err("aot division must fail"); + assert!( + matches!(aot_err, vm::VmError::JitNative(_)), + "pre-existing AOT division regression changed: {aot_err:?}" + ); +} diff --git a/tests/runtime_context_tests.rs b/tests/runtime_context_tests.rs new file mode 100644 index 00000000..2c2a05f3 --- /dev/null +++ b/tests/runtime_context_tests.rs @@ -0,0 +1,383 @@ +mod vm { + pub use ::vm::Value; +} + +#[allow(dead_code)] +#[path = "../src/builtins/runtime/cancellation.rs"] +mod cancellation; +#[allow(dead_code)] +#[path = "../src/builtins/runtime/context.rs"] +mod context; +#[allow(dead_code)] +#[path = "../src/builtins/runtime/error.rs"] +mod error; +#[allow(dead_code)] +#[path = "../src/builtins/runtime/event.rs"] +mod event; +#[allow(dead_code)] +#[path = "../src/builtins/runtime/resource.rs"] +mod resource; + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Barrier, Mutex}; +use std::time::{Duration, Instant}; + +use cancellation::{CancellationReason, OperationRegistry, OperationStatus}; +use context::{RuntimeContext, RuntimeContextConfig}; +use error::RuntimeErrorCode; +use event::{EventLimits, EventPayload}; +use resource::{CloseStatus, ResourceArena, ResourceHandle, ResourceTypeId}; +use vm::Value; + +#[test] +fn per_item_event_limits_are_run_scoped_configuration() { + let context = RuntimeContext::default(); + assert_eq!(context.event_limits(), EventLimits::default()); + assert_eq!(context.config().event_limits(), EventLimits::default()); + + let configured = RuntimeContext::with_config(RuntimeContextConfig::new( + EventLimits::new(8, 4).expect("test limits should be valid"), + )) + .expect("context should be constructible"); + assert_eq!(configured.event_limits().max_payload_bytes(), 8); + assert_eq!(configured.event_limits().max_depth(), 4); +} + +#[test] +fn event_payload_validates_the_per_item_bound_before_placement() { + let limits = EventLimits::new(8, 4).expect("test limits should be valid"); + + let payload = + EventPayload::try_new(Value::string("ok"), limits).expect("bounded event should validate"); + assert_eq!(payload.into_value(), Value::string("ok")); + + let too_large = EventPayload::try_new(Value::string("payload-too-large"), limits) + .expect_err("oversized event should be rejected"); + assert_eq!(too_large.code(), RuntimeErrorCode::EventPayloadTooLarge); + + let too_deep = EventPayload::try_new( + Value::array(vec![Value::array(vec![Value::array(vec![Value::Int(1)])])]), + EventLimits::new(1024, 2).expect("depth test limits should be valid"), + ) + .expect_err("too-deep event should be rejected"); + assert_eq!(too_deep.code(), RuntimeErrorCode::EventDepthExceeded); +} + +#[test] +fn resource_handles_are_opaque_bounded_typed_and_cleanup_is_idempotent() { + let cleanup_count = Arc::new(AtomicUsize::new(0)); + let count_for_cleanup = Arc::clone(&cleanup_count); + let mut arena = ResourceArena::with_limit(1).expect("resource limit should be valid"); + let handle = arena + .insert_with_cleanup(ResourceTypeId::IO_FILE, 7_u32, move |resource, reason| { + assert_eq!(resource, 7); + assert_eq!(reason, CancellationReason::ResourceClosed); + count_for_cleanup.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + .expect("first resource should be allocated"); + + assert_eq!( + arena + .get::(handle, ResourceTypeId::IO_FILE) + .expect("handle should resolve"), + &7 + ); + assert_eq!( + ResourceHandle::from_value(&handle.as_value()).expect("VM value should decode"), + handle + ); + let Value::Int(encoded) = handle.as_value() else { + unreachable!("resource handle should encode as an integer"); + }; + let forged_generation = ResourceHandle::from_value(&Value::Int(encoded + (1 << 8))) + .expect("the altered token remains structurally valid"); + let forged = arena + .get::(forged_generation, ResourceTypeId::IO_FILE) + .expect_err("an altered generation must not resolve"); + assert_eq!(forged.code(), RuntimeErrorCode::ResourceStale); + let wrong_type = arena + .get::(handle, ResourceTypeId::SQLITE_CONNECTION) + .expect_err("wrong resource type should be rejected"); + assert_eq!(wrong_type.code(), RuntimeErrorCode::ResourceTypeMismatch); + let limit_error = arena + .insert(ResourceTypeId::IO_FILE, 8_u32) + .expect_err("the bounded arena should reject a second resource"); + assert_eq!(limit_error.code(), RuntimeErrorCode::ResourceLimitExceeded); + + assert_eq!( + arena + .close(handle, CancellationReason::ResourceClosed) + .expect("close should succeed"), + CloseStatus::Closed + ); + assert_eq!( + arena + .close(handle, CancellationReason::ResourceClosed) + .expect("repeated close should be harmless"), + CloseStatus::AlreadyClosed + ); + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); + + let replacement = arena + .insert(ResourceTypeId::IO_FILE, 9_u32) + .expect("capacity should be reusable after close"); + assert_ne!( + replacement, handle, + "reusing a slot must change its generation" + ); + let closed = arena + .get::(handle, ResourceTypeId::IO_FILE) + .expect_err("the prior generation must not resolve after slot reuse"); + assert_eq!(closed.code(), RuntimeErrorCode::ResourceStale); + assert_eq!( + arena + .get::(replacement, ResourceTypeId::IO_FILE) + .expect("the replacement generation should resolve"), + &9 + ); +} + +#[test] +fn resource_handles_cannot_cross_resource_arenas() { + let mut first = ResourceArena::with_limit(1).expect("resource limit should be valid"); + let second = ResourceArena::with_limit(1).expect("resource limit should be valid"); + let handle = first + .insert(ResourceTypeId::IO_FILE, 1_u32) + .expect("resource should be allocated"); + + let error = second + .get::(handle, ResourceTypeId::IO_FILE) + .expect_err("a handle from another arena must be rejected"); + assert_eq!(error.code(), RuntimeErrorCode::ResourceHandleWrongTable); +} + +#[test] +fn cancellation_transitions_once_and_runs_cleanup_once() { + let cleanup_count = Arc::new(AtomicUsize::new(0)); + let count_for_cleanup = Arc::clone(&cleanup_count); + let mut registry = OperationRegistry::with_limit(2).expect("operation limit should be valid"); + let operation = registry + .start_owned( + cancellation::OperationOwner::Io, + None, + None, + Some(Box::new(move |end| { + assert_eq!( + end, + cancellation::OperationEnd::Cancelled(CancellationReason::Requested) + ); + count_for_cleanup.fetch_add(1, Ordering::SeqCst); + Ok(()) + })), + ) + .expect("operation should start"); + let token = operation.token(); + + assert_eq!(operation.status(), OperationStatus::Pending); + assert!( + operation + .cancel(CancellationReason::Requested) + .expect("cancel should succeed") + ); + assert!( + !operation + .cancel(CancellationReason::Requested) + .expect("cancel is idempotent") + ); + assert_eq!( + operation.status(), + OperationStatus::Cancelled(CancellationReason::Requested) + ); + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); + let cancelled = token + .check() + .expect_err("the cancellation token should stop the operation"); + assert_eq!(cancelled.code(), RuntimeErrorCode::OperationCancelled); + assert!( + !operation + .complete() + .expect("terminal operation should remain terminal") + ); +} + +#[test] +fn cancellation_after_completion_does_not_reopen_or_relabel_operation() { + let mut registry = OperationRegistry::with_limit(2).expect("operation limit should be valid"); + let operation = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect("operation should start"); + assert!(operation.complete().expect("operation should complete")); + assert!( + !operation + .cancel(CancellationReason::Requested) + .expect("cancel is idempotent") + ); + assert_eq!(operation.status(), OperationStatus::Completed); + assert!(!operation.token().is_cancelled()); +} + +#[test] +fn operation_registry_bounds_active_operations_and_releases_cancelled_state() { + let mut registry = OperationRegistry::with_limit(1).expect("operation limit should be valid"); + let operation = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect("first operation should start"); + let limit_error = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect_err("active operation limit should be enforced"); + assert_eq!(limit_error.code(), RuntimeErrorCode::OperationLimitExceeded); + + assert!( + registry + .cancel(operation.id(), CancellationReason::VmReset) + .expect("registry cancellation should succeed") + ); + assert_eq!(registry.active_count(), 0); + assert!(matches!( + operation.status(), + OperationStatus::Cancelled(CancellationReason::VmReset) + )); +} + +#[test] +fn registry_retains_terminal_result_until_it_is_consumed() { + let mut registry = OperationRegistry::with_limit(1).expect("operation limit should be valid"); + let operation = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect("operation should start"); + assert!(operation.complete().expect("completion should succeed")); + + let limit_error = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect_err("unconsumed terminal result should retain its registry slot"); + assert_eq!(limit_error.code(), RuntimeErrorCode::OperationLimitExceeded); + assert!(registry.get(operation.id()).is_ok()); + + assert!( + !registry + .complete(operation.id()) + .expect("consuming an already completed operation should succeed") + ); + assert!(registry.get(operation.id()).is_err()); + registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect("consuming the terminal result should release capacity"); +} + +#[test] +fn concurrent_completion_and_cancellation_choose_one_terminal_state() { + let cleanup_count = Arc::new(AtomicUsize::new(0)); + let cleanup_for_operation = Arc::clone(&cleanup_count); + let mut registry = OperationRegistry::with_limit(2).expect("operation limit should be valid"); + let operation = registry + .start_owned( + cancellation::OperationOwner::Io, + None, + None, + Some(Box::new(move |_| { + cleanup_for_operation.fetch_add(1, Ordering::SeqCst); + Ok(()) + })), + ) + .expect("operation should start"); + let barrier = Arc::new(Barrier::new(3)); + + let complete_operation = operation.clone(); + let complete_barrier = Arc::clone(&barrier); + let complete = std::thread::spawn(move || { + complete_barrier.wait(); + complete_operation + .complete() + .expect("completion should run") + }); + + let cancel_operation = operation.clone(); + let cancel_barrier = Arc::clone(&barrier); + let cancel = std::thread::spawn(move || { + cancel_barrier.wait(); + cancel_operation + .cancel(CancellationReason::Requested) + .expect("cancellation should run") + }); + + barrier.wait(); + let terminal_wins = usize::from(complete.join().expect("completion thread")) + + usize::from(cancel.join().expect("cancellation thread")); + assert_eq!(terminal_wins, 1); + assert_eq!(cleanup_count.load(Ordering::SeqCst), 1); + match operation.status() { + OperationStatus::Completed => assert_eq!(operation.token().reason(), None), + OperationStatus::Cancelled(reason) => { + assert_eq!(reason, CancellationReason::Requested); + assert_eq!(operation.token().reason(), Some(reason)); + } + status => panic!("unexpected terminal state: {status:?}"), + } +} + +#[test] +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) + .expect("parent should start"); + let child = registry + .start_owned( + cancellation::OperationOwner::Io, + Some(&parent.token()), + None, + None, + ) + .expect("child should start"); + + assert!(child.complete().expect("child should complete")); + assert!( + parent + .cancel(CancellationReason::Requested) + .expect("parent should cancel") + ); + assert_eq!(child.status(), OperationStatus::Completed); + assert_eq!(child.token().reason(), None); +} + +#[test] +fn expired_deadline_is_the_status_token_and_cleanup_reason() { + let cleanup_end = Arc::new(Mutex::new(None)); + let cleanup_end_for_operation = Arc::clone(&cleanup_end); + let mut registry = OperationRegistry::with_limit(4).expect("operation limit should be valid"); + let parent = registry + .start_owned(cancellation::OperationOwner::Io, None, None, None) + .expect("parent should start"); + let operation = registry + .start_owned( + cancellation::OperationOwner::Io, + Some(&parent.token()), + Some(Instant::now() - Duration::from_millis(1)), + Some(Box::new(move |end| { + *cleanup_end_for_operation.lock().expect("cleanup lock") = Some(end); + Ok(()) + })), + ) + .expect("deadline child should start"); + + assert!( + operation + .cancel(CancellationReason::Requested) + .expect("deadline cancellation should run") + ); + assert_eq!( + operation.token().reason(), + Some(CancellationReason::Deadline) + ); + assert_eq!( + operation.status(), + OperationStatus::Cancelled(CancellationReason::Deadline) + ); + assert_eq!( + *cleanup_end.lock().expect("cleanup lock"), + Some(cancellation::OperationEnd::Cancelled( + CancellationReason::Deadline + )) + ); +} diff --git a/tests/runtime_host_tests.rs b/tests/runtime_host_tests.rs new file mode 100644 index 00000000..71772232 --- /dev/null +++ b/tests/runtime_host_tests.rs @@ -0,0 +1,125 @@ +#![cfg(feature = "runtime")] + +#[cfg(feature = "sqlite")] +use vm::SqliteHostExt; +use vm::{ + HostFunctionRegistry, InvocationError, InvocationItem, InvocationPoll, Value, Vm, VmError, + VmStatus, compile_source, +}; + +/// Compiles a source, binds the default runtime host registry, and completes +/// the root frame so exported callables can be started. +fn prepared_vm(source: &str) -> Vm { + let program = compile_source(source) + .expect("runtime host source should compile") + .program; + let mut vm = Vm::new(program); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default runtime host registry should bind"); + assert_eq!(vm.run().expect("root frame should halt"), VmStatus::Halted); + vm +} + +#[test] +fn invocation_input_arrives_through_exported_callable_arguments() { + let mut vm = prepared_vm( + r#" + pub fn run(input: string) -> string { + input; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![Value::string("run-input")]) + .expect("invocation should start"); + + match invocation.poll_next().expect("poll should succeed") { + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(value)))) => { + assert_eq!(value, Value::string("run-input")); + } + other => panic!("expected the callable input as the Complete value, got {other:?}"), + } + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn stream_emit_delivers_events_through_the_invocation_stream() { + let mut vm = prepared_vm( + r#" + use stream; + pub fn run() -> string { + stream::emit("event-one"); + stream::emit("event-two"); + "done"; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("invocation should start"); + + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("event-one") + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Event(value)))) if value == Value::string("event-two") + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(value)))) if value == Value::string("done") + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[test] +fn invocation_errors_are_typed_without_string_parsing() { + let mut vm = prepared_vm( + r#" + pub fn run(input: int) -> int { + 1 / input; + } + "#, + ); + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, vec![Value::Int(0)]) + .expect("invocation should start"); + + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Err(InvocationError::Vm(VmError::DivisionByZero)))) + )); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(None) + )); +} + +#[cfg(feature = "sqlite")] +#[test] +fn public_sqlite_policy_configures_the_production_vm() { + let program = compile_source("0;") + .expect("minimal SQLite host program should compile") + .program; + let mut vm = Vm::new(program); + vm.configure_sqlite(vm::SqlitePolicy::default()); + let _limits = vm::SqliteLimits::default(); + vm.clear_sqlite_configuration(); +} diff --git a/tests/support/async_test_bridge.rs b/tests/support/async_test_bridge.rs new file mode 100644 index 00000000..179abebd --- /dev/null +++ b/tests/support/async_test_bridge.rs @@ -0,0 +1,70 @@ +use std::collections::HashMap; +use std::task::{Context, Poll}; + +use vm::vm::{HostFuture, HostFutureOutput}; +use vm::{CallReturn, HostAsyncBridge, HostOpId, Vm, VmError, VmResult}; + +struct TokioTestBridge { + runtime: tokio::runtime::Runtime, + futures: HashMap, +} + +impl TokioTestBridge { + fn new() -> Self { + Self { + runtime: tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("test runtime should build"), + futures: HashMap::new(), + } + } +} + +impl HostAsyncBridge for TokioTestBridge { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + if self.futures.insert(op_id, future).is_some() { + return Err(VmError::HostError(format!( + "duplicate submitted host op {op_id}" + ))); + } + Ok(()) + } + + fn poll_op(&mut self, op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(VmError::HostError(format!( + "unexpected external op {op_id}" + )))) + } + + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll>> { + let poll = { + let future = match self.futures.get_mut(&op_id) { + Some(future) => future, + None => { + return Poll::Ready(Err(VmError::HostError(format!( + "unknown submitted host op {op_id}" + )))); + } + }; + let _guard = self.runtime.enter(); + future.as_mut().poll(cx) + }; + if poll.is_ready() { + self.futures.remove(&op_id); + } + poll + } + + fn cancel_op(&mut self, op_id: HostOpId) { + self.futures.remove(&op_id); + } +} + +pub(crate) fn install(vm: &mut Vm) { + vm.set_async_bridge(Box::new(TokioTestBridge::new())); +} diff --git a/tests/vm/call_script_tests.rs b/tests/vm/call_script_tests.rs new file mode 100644 index 00000000..f0ea3d48 --- /dev/null +++ b/tests/vm/call_script_tests.rs @@ -0,0 +1,423 @@ +//! Milestone 6: `CallScript` interpreter entry tests. +//! +//! These tests build raw `CallScript` bytecode (0x1A, prototype_id:u32 LE, +//! argc:u8) with hand-written callable metadata so the interpreter contract +//! is pinned independently of the compiler: frame entry, resume +//! continuation, operand stack cleanup, typed failures, depth limits, and +//! interruption ticks. +#[path = "../common/mod.rs"] +mod common; +use common::*; + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use vm::{ + CallableKind, CallablePrototype, CallableTarget, FunctionRegion, Program, ScriptFunction, + Value, VmError, VmStatus, +}; + +/// Build a program whose root body is `root_prefix` followed by +/// `CallScript(prototype_id, argc)` and `ret`; the callee body is supplied +/// as raw bytes. Callable metadata describes one prototype with the given +/// arity/target/captures/self slot. +#[allow(clippy::too_many_arguments)] +fn call_script_program( + prototype_id: u32, + argc: u8, + arity: u8, + target: CallableTarget, + capture_slots: Vec, + self_slot: Option, + root_prefix: Vec, + callee_body: Vec, +) -> Program { + let mut code = root_prefix; + code.push(0x1A); + code.extend_from_slice(&prototype_id.to_le_bytes()); + code.push(argc); + code.push(0x01); // ret + let function_entry = code.len() as u32; + code.extend_from_slice(&callee_body); + let function_end = code.len() as u32; + + Program::new(vec![Value::Int(41), Value::Int(1)], code) + .with_local_count(1) + .with_callable_metadata( + vec![ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![CallablePrototype { + kind: CallableKind::FunctionItem, + target, + arity, + frame_local_count: 1, + parameter_slots: (0..arity).map(u16::from).collect(), + capture_source_slots: Vec::new(), + capture_slots, + capture_modes: Vec::new(), + self_slot, + schema: None, + }], + vec![ + FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + vec![], + ) +} + +/// A program whose callee (prototype 0) recursively calls itself through +/// `CallScript` with no arguments until the depth limit stops it. +fn call_script_recursion_program() -> Program { + // Root body: CallScript(0, 0), ret. + let mut code = vec![0x1A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]; + let function_entry = code.len() as u32; + // Callee body: CallScript(0, 0), ret. + code.extend_from_slice(&[0x1A, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]); + let function_end = code.len() as u32; + + Program::new(Vec::new(), code) + .with_local_count(1) + .with_callable_metadata( + vec![ScriptFunction { + entry_ip: function_entry, + end_ip: function_end, + }], + vec![CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 1, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }], + vec![ + FunctionRegion { + start_ip: 0, + end_ip: function_entry, + prototype_id: None, + }, + FunctionRegion { + start_ip: function_entry, + end_ip: function_end, + prototype_id: Some(0), + }, + ], + vec![], + ) +} + +/// Callee body that returns `local 0 + 1` (parameter + 1). +fn callee_param_plus_one() -> Vec { + vec![0x0F, 0x00, 0x02, 0x01, 0x00, 0x00, 0x00, 0x03, 0x01] +} + +#[test] +fn call_script_enters_script_frame_and_resumes_caller() { + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], // ldc 0 (41) + callee_param_plus_one(), + ); + let mut vm = Vm::new(program); + assert_eq!(vm.run().expect("script call should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + assert_eq!(vm.call_depth(), 0); +} + +#[test] +fn call_script_preserves_caller_stack_below_operands() { + // Root: ldc 0 (41), ldc 0 (41), CallScript(0, 1), ret. The first value + // sits below the operand stack base and must survive the nested frame. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00], + callee_param_plus_one(), + ); + let mut vm = Vm::new(program); + assert_eq!(vm.run().expect("script call should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(41), Value::Int(42)]); + assert_eq!(vm.call_depth(), 0); +} + +#[test] +fn call_script_rejects_stack_underflow() { + // argc is 2 but only one value is pushed. + let program = call_script_program( + 0, + 2, + 2, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!(vm.run(), Err(VmError::StackUnderflow))); +} + +#[test] +fn call_script_rejects_invalid_prototype_id() { + let program = call_script_program( + 99, + 1, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::InvalidCallablePrototype(99)) + )); +} + +#[test] +fn call_script_rejects_invalid_script_function_id() { + // The prototype exists, passes the environment and arity checks, but + // its `ScriptFunction` target id is out of range for the program's + // script-function table. The lookup must fail with the same typed + // error used for the missing-prototype branch rather than entering a + // bogus frame. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(5), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::InvalidCallablePrototype(0)) + )); +} + +#[test] +fn call_script_rejects_wrong_arity() { + // Prototype declares arity 1 but the call passes 2 operands. + let program = call_script_program( + 0, + 2, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::CallableArityMismatch { + prototype_id: 0, + expected: 1, + got: 2 + }) + )); +} + +#[test] +fn call_script_rejects_non_script_prototype() { + // `CallScript` is a static script-function call: a host-import + // prototype must be rejected instead of routing to the host path. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::HostImport(0), + Vec::new(), + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::InvalidCallablePrototype(0)) + )); +} + +#[test] +fn call_script_preserves_script_depth_limit() { + let program = call_script_recursion_program(); + let mut vm = Vm::new(program); + vm.set_max_script_call_depth(3) + .expect("positive depth should be accepted"); + assert!(matches!( + vm.run(), + Err(VmError::CallStackOverflow { limit: 3 }) + )); +} + +#[test] +fn call_script_frame_entry_charges_interruption_ticks() { + // Frame entry through `CallScript` must charge interruption ticks like + // `CallValue`: with a tiny fuel budget the recursion exhausts fuel and + // the vm yields with the fuel reason instead of looping forever. + let program = call_script_recursion_program(); + let mut vm = Vm::new(program); + vm.set_fuel_check_interval(1) + .expect("interval update should succeed"); + vm.set_fuel(2); + let status = vm.run().expect("run should yield on fuel exhaustion"); + assert_eq!(status, VmStatus::Yielded); + assert_eq!(vm.get_fuel(), Some(0)); +} + +#[test] +fn call_script_rejects_capture_required_prototype() { + // `CallScript` supplies no callable environment: a prototype whose + // capture layout requires cells must be rejected with a typed error. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(0), + vec![1], + None, + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::CallScriptRequiresEnvironment(0)) + )); +} + +#[test] +fn call_script_recursion_resumes_caller_locals_intact() { + // Direct recursion through `CallScript`: each frame keeps its own + // parameter value, and the caller's locals survive the nested calls. + let source = r#" + fn countdown(n: int) -> int { + if n <= 0 => { 0 } else => { countdown(n - 1) } + } + let keep = "alive"; + countdown(3); + keep; + "#; + let compiled = compile_source(source).expect("recursion source should compile"); + let mut vm = Vm::new(compiled.program); + let status = vm.run().expect("vm should run"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(0), Value::string("alive")]); +} + +/// Host function that reports `Pending` once; the test delivers the +/// completion through `complete_host_op`. +struct PendingOnceHostOp { + call_count: Arc, + op_id: u64, +} + +impl HostFunction for PendingOnceHostOp { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> Result { + self.call_count.fetch_add(1, Ordering::SeqCst); + Ok(CallOutcome::Pending(self.op_id)) + } +} + +#[test] +fn call_script_rejects_self_slot_required_prototype() { + // `CallScript` supplies no callable environment: a prototype that + // requires a self binding is rejected with a typed error even when its + // capture layout is empty. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + Some(0), + vec![0x02, 0x00, 0x00, 0x00, 0x00], + vec![0x01], + ); + let mut vm = Vm::new(program); + assert!(matches!( + vm.run(), + Err(VmError::CallScriptRequiresEnvironment(0)) + )); +} + +#[test] +fn call_script_callee_host_wait_resumes_caller_continuation() { + // The callee suspends mid-body on a host operation. After the host op + // completes, the callee frame resumes with its local state intact and + // returns through the `CallScript` continuation, which finishes with + // the caller stack below the call operands preserved. + let program = call_script_program( + 0, + 1, + 1, + CallableTarget::ScriptFunction(0), + Vec::new(), + None, + // Root: ldc 0 (41), ldc 0 (41), CallScript(0, 1), ret. The first + // 41 sits below the operand stack base and must survive the + // nested frame and the suspension. + vec![0x02, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00], + // Callee: Call(host 0, 0), ldloc 0 (parameter), ret. + vec![0x11, 0x00, 0x00, 0x00, 0x0F, 0x00, 0x01], + ); + let calls = Arc::new(AtomicUsize::new(0)); + let mut vm = Vm::new(program); + vm.register_function(Box::new(PendingOnceHostOp { + call_count: Arc::clone(&calls), + op_id: 802, + })); + + let status = vm.run().expect("first run should wait"); + assert_eq!(status, VmStatus::Waiting(802)); + assert_eq!(calls.load(Ordering::SeqCst), 1, "host op should run once"); + + vm.complete_host_op(802, Vec::new()) + .expect("host op completion should succeed"); + let status = vm.resume().expect("resume should halt"); + assert_eq!(status, VmStatus::Halted); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "resume must not re-enter the host function" + ); + assert_eq!( + vm.stack(), + &[Value::Int(41), Value::Int(41)], + "caller stack below the operands and the callee result must survive the suspension" + ); + assert_eq!(vm.call_depth(), 0); +} diff --git a/tests/vm/drop_contract_tests.rs b/tests/vm/drop_contract_tests.rs index f6ead6a3..4aea31c4 100644 --- a/tests/vm/drop_contract_tests.rs +++ b/tests/vm/drop_contract_tests.rs @@ -894,3 +894,141 @@ fn all_locals_null_after_halt_for_simple_program() { "c should be Null" ); } + +// --------------------------------------------------------------------------- +// 14. Named-call cross-frame drops +// --------------------------------------------------------------------------- + +#[test] +fn named_call_cross_frame_heap_values_drop_exactly_once() { + // Caller and callee frames each own heap values around a named call. + // Each additional dead callee-produced map must add exactly its own drop + // events: no double-drop of the caller's value, no omission of the + // callee's. + let drops_one = compile_run_drop_count( + r#" + fn pass(x) { x } + let a = { tag: "a" }; + let b = pass({ tag: "b" }); + 0; + "#, + ); + let drops_two = compile_run_drop_count( + r#" + fn pass(x) { x } + let a = { tag: "a" }; + let b = pass({ tag: "b" }); + let c = pass({ tag: "c" }); + 0; + "#, + ); + assert!( + drops_two > drops_one, + "more dead values across named calls should produce more drop events ({drops_two} vs {drops_one})" + ); + // The delta is exactly one extra map (map + key + value events) plus one + // extra call's machinery; a double-drop or an omitted drop would change it. + // Direct-only named calls (`CallScript`) no longer materialize a callable + // value, so the per-call machinery drops one event fewer than the + // `CallValue`-era baseline. + assert_eq!( + drops_two - drops_one, + 6, + "named calls should add exactly one map and one call of drop events" + ); +} + +#[test] +fn named_call_yield_resumes_with_caller_locals_intact() { + // A named callee suspends on a host op; the caller's heap local must + // survive the suspension, and the drop count must match the unsuspended + // control exactly. + let plain_source = r#" + fn paused(x) { + x; + } + let caller = { tag: "caller" }; + let back = paused({ tag: "callee" }); + 0; + "#; + let wait_source = r#" + fn wait(); + fn paused(x) { + wait(); + x; + } + let caller = { tag: "caller" }; + let back = paused({ tag: "callee" }); + 0; + "#; + let plain = compile_run_drop_count(plain_source); + + let compiled = compile_source(wait_source).expect("compile should succeed"); + let calls = Arc::new(AtomicUsize::new(0)); + let mut vm = new_drop_contract_vm(compiled.program); + vm.register_function(Box::new(PendingOnce { + call_count: Arc::clone(&calls), + op_id: 802, + })); + + let status = vm.run().expect("first run should wait"); + assert_eq!(status, VmStatus::Waiting(802)); + vm.complete_host_op(802, Vec::new()) + .expect("complete should succeed"); + let status = vm.resume().expect("resume should halt"); + assert_eq!(status, VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(0)]); + assert_eq!(calls.load(Ordering::SeqCst), 1, "host op should run once"); + + assert_eq!( + vm.drop_contract_event_count(), + plain, + "suspension must not add or remove drop events" + ); +} + +// --------------------------------------------------------------------------- +// 11. Direct script-call (CallScript) drop behavior +// --------------------------------------------------------------------------- + +#[test] +fn direct_script_call_preserves_drop_contract() { + // A named helper invoked through the direct script-call path drops its + // dead heap locals exactly once per value and restores the caller + // stack. The callee's parameter (an int) and its dead string local are + // both dropped when the callee frame completes. + let source = r#" + fn consume(value: int) -> int { + let tmp = "temp"; + value + 1 + } + consume(41); + "#; + let drops = compile_run_drop_count(source); + assert_eq!( + drops, 2, + "callee parameter and tmp string each drop exactly once, got {drops}" + ); + let vm = compile_run_vm(source); + assert_eq!(vm.stack(), &[Value::Int(42)]); +} + +#[test] +fn direct_script_call_preserves_caller_heap_values() { + // A caller heap local must survive a direct script call and drop only + // at the root frame's end; the callee's scalar parameter drops in the + // callee frame. + let source = r#" + fn bump(value: int) -> int { value + 1 } + let keep = "alive"; + bump(1); + keep; + "#; + let drops = compile_run_drop_count(source); + assert_eq!( + drops, 2, + "callee parameter and caller keep string each drop once, got {drops}" + ); + let vm = compile_run_vm(source); + assert_eq!(vm.stack(), &[Value::Int(2), Value::string("alive")]); +} diff --git a/tests/vm/http_host_tests.rs b/tests/vm/http_host_tests.rs new file mode 100644 index 00000000..531cd74c --- /dev/null +++ b/tests/vm/http_host_tests.rs @@ -0,0 +1,357 @@ +use std::collections::HashMap; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::task::{Context, Poll}; +use std::thread; + +use vm::{ + CallOutcome, CallReturn, HostAsyncBridge, HostFunctionRegistry, HostFuture, HostFutureOutput, + HostOpId, HttpConfig, HttpHostExt, Program, Value, Vm, VmError, VmResult, VmStatus, + compile_source, +}; + +#[derive(Default)] +struct TokioHostDriver { + submitted: HashMap, +} + +impl HostAsyncBridge for TokioHostDriver { + fn submit_op(&mut self, op_id: HostOpId, future: HostFuture) -> VmResult<()> { + self.submitted.insert(op_id, future); + Ok(()) + } + + fn poll_op(&mut self, op_id: HostOpId, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Err(VmError::HostError(format!( + "unknown external host operation {op_id}" + )))) + } + + fn poll_submitted_op( + &mut self, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + let poll = self.submitted.get_mut(&op_id).map_or_else( + || { + Poll::Ready(Err(VmError::HostError(format!( + "unknown submitted host operation {op_id}" + )))) + }, + |future| future.as_mut().poll(cx), + ); + if poll.is_ready() { + self.submitted.remove(&op_id); + } + poll + } + + fn cancel_op(&mut self, op_id: HostOpId) { + self.submitted.remove(&op_id); + } +} + +fn install_host_driver(vm: &mut Vm) { + vm.set_async_bridge(Box::::default()); +} + +fn build_request_program(url: String) -> Program { + compile_source(&format!( + r#" + use http; + http::client::request({{"method": "GET", "url": "{url}"}}); + "# + )) + .expect("HTTP request source should compile") + .program +} + +fn local_http_config(port: u16) -> HttpConfig { + HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![port], + allow_private_ips: true, + ..HttpConfig::default() + } +} + +fn spawn_test_server() -> (u16, thread::JoinHandle<()>) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("test listener should bind"); + let port = listener + .local_addr() + .expect("test listener should have an address") + .port(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("test request should arrive"); + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let read = stream + .read(&mut buffer) + .expect("request should be readable"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + assert!(request.starts_with(b"GET / HTTP/1.1")); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nX-Test: yes\r\n\r\nok") + .expect("response should be writable"); + }); + (port, handle) +} + +fn response_field<'a>(value: &'a Value, key: &str) -> &'a Value { + let Value::Map(map) = value else { + panic!("expected response map, got {value:?}"); + }; + map.get(&Value::string(key)) + .unwrap_or_else(|| panic!("response missing field {key}")) +} + +async fn drive_vm_to_halt(vm: &mut Vm) -> Result<(), vm::VmError> { + let mut status = vm.run()?; + loop { + match status { + VmStatus::Halted => return Ok(()), + VmStatus::Yielded => status = vm.resume()?, + VmStatus::Waiting(_) => { + vm.await_waiting_host_op().await?; + status = vm.resume()?; + } + } + } +} + +#[tokio::test(flavor = "current_thread")] +async fn http_host_executes_a_bounded_request_and_returns_a_response_map() { + let (port, server) = spawn_test_server(); + let mut vm = Vm::new(build_request_program(format!("http://127.0.0.1:{port}/"))); + vm.configure_http(local_http_config(port)); + install_host_driver(&mut vm); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + + drive_vm_to_halt(&mut vm) + .await + .expect("http request should complete"); + server.join().expect("test server should finish"); + + assert_eq!(response_field(&vm.stack()[0], "status"), &Value::Int(200)); + assert_eq!( + response_field(&vm.stack()[0], "body"), + &Value::bytes(b"ok".to_vec()) + ); +} + +#[test] +fn http_host_rejects_targets_until_an_explicit_policy_allows_them() { + let mut vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + let error = vm + .run() + .expect_err("unconfigured HTTP targets must be rejected"); + assert!( + error.to_string().contains("HTTP host is not configured") + || error + .to_string() + .contains("HTTP target host is not allowed"), + "unexpected error: {error}" + ); +} + +#[test] +fn empty_registry_keeps_language_builtins_but_rejects_http_capability() { + let mut language_vm = Vm::new( + vm::compile_source("assert(true);") + .expect("language builtin program should compile") + .program, + ); + HostFunctionRegistry::empty() + .bind_vm_cached(&mut language_vm) + .expect("empty registry should bind a program without host imports"); + assert_eq!( + language_vm.run().expect("language builtin should run"), + VmStatus::Halted + ); + + let mut http_vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + let error = HostFunctionRegistry::restricted() + .bind_vm_cached(&mut http_vm) + .expect_err("unapproved HTTP capability must fail during preflight"); + assert!(error.to_string().contains("http::client::request")); +} + +#[test] +fn restricted_registry_requires_explicit_namespaced_builtin_capability() { + let compiled = compile_source( + r#"use io; +io::open("/tmp/rustscript-capability-test", "r");"#, + ) + .expect("namespaced host builtin should compile"); + let mut vm = Vm::new(compiled.program); + let error = HostFunctionRegistry::restricted() + .bind_vm_cached(&mut vm) + .expect_err("ungranted namespaced builtin must fail during preflight"); + assert!(error.to_string().contains("io_open")); +} + +#[test] +fn capability_binding_plan_cannot_cross_registry_profiles() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let unrestricted = HostFunctionRegistry::new(); + let plan = unrestricted + .prepare_plan(&program.imports) + .expect("unrestricted registry should prepare HTTP plan"); + let mut vm = Vm::new(program); + let error = HostFunctionRegistry::restricted() + .bind_vm_with_plan(&mut vm, &plan) + .expect_err("capability plan must not cross registry profiles"); + assert!(error.to_string().contains("different capability profile")); +} + +#[test] +fn capability_binding_plan_cannot_outlive_registry_mutation() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let mut registry = HostFunctionRegistry::new(); + let plan = registry + .prepare_plan(&program.imports) + .expect("registry should prepare HTTP plan"); + registry + .allow_builtin("http::client::request") + .expect("HTTP capability should be a known host callable"); + let mut vm = Vm::new(program); + let error = registry + .bind_vm_with_plan(&mut vm, &plan) + .expect_err("stale capability plan must not bind"); + assert!(error.to_string().contains("different capability profile")); +} + +#[test] +fn capability_binding_plan_detects_divergent_registry_clone_mutations() { + let unchanged_program = build_request_program("http://127.0.0.1:1/".to_string()); + let mut unchanged_registry = HostFunctionRegistry::restricted(); + unchanged_registry + .allow_builtin("http::client::request") + .expect("HTTP capability should be known"); + let unchanged_plan = unchanged_registry + .prepare_plan(&unchanged_program.imports) + .expect("restricted registry should prepare HTTP plan"); + let unchanged_clone = unchanged_registry.clone(); + let mut unchanged_vm = Vm::new(unchanged_program); + unchanged_clone + .bind_vm_with_plan(&mut unchanged_vm, &unchanged_plan) + .expect("an unchanged registry clone should reuse the plan"); + + let branch_program = build_request_program("http://127.0.0.1:1/".to_string()); + let branch_registry = HostFunctionRegistry::restricted(); + let mut first_mutation = branch_registry.clone(); + let mut second_mutation = branch_registry; + first_mutation + .allow_builtin("http::client::request") + .expect("HTTP capability should be known"); + second_mutation + .allow_builtin("io::open") + .expect("io capability should be known"); + let plan = first_mutation + .prepare_plan(&branch_program.imports) + .expect("first capability branch should prepare HTTP plan"); + let mut mutated_vm = Vm::new(branch_program); + let error = second_mutation + .bind_vm_with_plan(&mut mutated_vm, &plan) + .expect_err("divergent capability branches must reject each other's plan"); + assert!(error.to_string().contains("different capability profile")); +} + +#[test] +fn registry_state_rejects_structural_sibling_mutations() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let registry = HostFunctionRegistry::new(); + let mut source = registry.clone(); + let destination = registry; + source.register_static_args("test::structural", 0, |_args| { + Ok(CallOutcome::Return(CallReturn::One(Value::Null))) + }); + let plan = source + .prepare_plan(&program.imports) + .expect("mutated source registry should prepare HTTP plan"); + let mut vm = Vm::new(program); + let error = destination + .bind_vm_with_plan(&mut vm, &plan) + .expect_err("structural sibling mutation must reject the plan"); + assert!(error.to_string().contains("different registry state")); +} + +#[test] +fn cached_plan_refreshes_after_a_sibling_registry_mutation() { + let program = build_request_program("http://127.0.0.1:1/".to_string()); + let registry = HostFunctionRegistry::new(); + let mut mutating_sibling = registry.clone(); + let destination = registry; + + let mut priming_vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + destination + .bind_vm_cached(&mut priming_vm) + .expect("destination should prime its plan cache"); + mutating_sibling.register_static_args("test::cache_refresh", 0, |_args| { + Ok(CallOutcome::Return(CallReturn::One(Value::Null))) + }); + + let mut refreshed_vm = Vm::new(program); + destination + .bind_vm_cached(&mut refreshed_vm) + .expect("destination should rebuild a plan after sibling mutation"); +} + +#[tokio::test(flavor = "current_thread")] +async fn explicitly_allowed_http_capability_reaches_http_policy() { + let mut vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + vm.configure_http(HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![1], + allow_private_ips: true, + ..HttpConfig::default() + }); + install_host_driver(&mut vm); + let mut registry = HostFunctionRegistry::restricted(); + registry + .allow_builtin("http::client::request") + .expect("HTTP builtin should be explicitly allowlisted"); + registry + .bind_vm_cached(&mut vm) + .expect("explicit capability plan should bind"); + let error = drive_vm_to_halt(&mut vm) + .await + .expect_err("connection failure should reach HTTP runtime"); + assert!(!matches!(error, vm::VmError::UnboundImport(_))); +} + +#[test] +fn http_in_flight_limit_rejects_before_starting_a_request() { + let mut vm = Vm::new(build_request_program("http://127.0.0.1:1/".to_string())); + vm.set_http_max_in_flight(0); + vm.configure_http(HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![1], + allow_private_ips: true, + + ..HttpConfig::default() + }); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("default host registry should bind HTTP"); + let error = vm + .run() + .expect_err("zero in-flight capacity must reject the request"); + assert!(error.to_string().contains("in-flight request limit")); +} diff --git a/tests/vm/ownership_tests.rs b/tests/vm/ownership_tests.rs new file mode 100644 index 00000000..95c2eefd --- /dev/null +++ b/tests/vm/ownership_tests.rs @@ -0,0 +1,407 @@ +//! Milestone 1 of the VM runtime decomposition plan: ownership tests. +//! +//! These tests pin the ownership contract through the public embedding API: +//! - one immutable program can create multiple isolated instances; +//! - invocation input/events/budgets never leak between runs; +//! - backend caches may be shared without sharing stacks/resources; +//! - reset closes run-scoped state and retains only documented reusable state. + +#[path = "../common/mod.rs"] +mod common; +use common::*; + +use std::sync::Arc; + +use vm::{HostFunctionRegistry, InvocationError, InvocationItem, InvocationPoll, Value, VmStatus}; + +fn non_yielding_returns_zero(_: &[Value]) -> Result { + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(0)))) +} + +fn non_yielding_returns_seven(_: &[Value]) -> Result { + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(7)))) +} + +fn non_yielding_returns_nine(_: &[Value]) -> Result { + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(9)))) +} + +fn non_yielding_returns_forty_two(_: &[Value]) -> Result { + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(42)))) +} + +/// Drives one exported `run` callable to the end of its invocation stream. +fn collect_invocation_items( + vm: &mut vm::Vm, + args: Vec, +) -> Vec> { + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + let mut invocation = vm + .start_invocation(callable, args) + .expect("invocation should start"); + let mut items = Vec::new(); + loop { + match invocation + .poll_next() + .expect("invocation poll should not fail") + { + InvocationPoll::Ready(Some(item)) => items.push(item), + InvocationPoll::Ready(None) => break, + InvocationPoll::Pending => std::thread::sleep(std::time::Duration::from_millis(1)), + } + } + items +} + +struct PendingOneHost; + +impl vm::HostArgsFunction for PendingOneHost { + fn call(&mut self, _args: &[Value]) -> vm::VmResult { + Ok(vm::CallOutcome::Pending(1)) + } +} + +/// One immutable program produces independent instances: each invocation keeps +/// its own stream items, and no instance observes another's execution. +#[test] +fn one_immutable_program_creates_multiple_isolated_instances() { + let program = Arc::new( + compile_source( + r#" + pub fn run(input: string) -> string { + input; + } + "#, + ) + .expect("source should compile") + .program, + ); + + let mut first = Vm::new_shared(Arc::clone(&program)); + let mut second = Vm::new_shared(Arc::clone(&program)); + HostFunctionRegistry::new() + .bind_vm_cached(&mut first) + .expect("runtime hosts should bind"); + HostFunctionRegistry::new() + .bind_vm_cached(&mut second) + .expect("runtime hosts should bind"); + assert_eq!( + first.run().expect("first root should halt"), + VmStatus::Halted + ); + assert_eq!( + second.run().expect("second root should halt"), + VmStatus::Halted + ); + + let first_items = collect_invocation_items(&mut first, vec![Value::string("first")]); + let second_items = collect_invocation_items(&mut second, vec![Value::string("second")]); + + assert_eq!(first_items.len(), 1, "first invocation must complete once"); + assert!( + matches!(&first_items[0], Ok(InvocationItem::Complete(value)) if *value == Value::string("first")), + "first instance must observe its own input, got {first_items:?}" + ); + assert_eq!( + second_items.len(), + 1, + "second invocation must complete once" + ); + assert!( + matches!(&second_items[0], Ok(InvocationItem::Complete(value)) if *value == Value::string("second")), + "second instance must observe its own input, got {second_items:?}" + ); + + // Re-running one instance after reset must not disturb the other. Reset + // rewinds the root frame, so the root must halt again before callables can + // be started. + first.reset_for_reuse(); + assert_eq!( + first.run().expect("first root should halt again"), + VmStatus::Halted + ); + let first_again = collect_invocation_items(&mut first, vec![Value::string("first-again")]); + assert!( + matches!(&first_again[0], Ok(InvocationItem::Complete(value)) if *value == Value::string("first-again")), + "first rerun must observe its own fresh input, got {first_again:?}" + ); + assert_eq!( + second_items.len(), + 1, + "first's rerun must not disturb second" + ); +} + +/// Invocation events and results are run-scoped: a reset closes them, and a +/// later run starts with a clean stream. +#[test] +fn run_input_and_events_do_not_leak_between_runs() { + let program = Arc::new( + compile_source( + r#" + use stream; + pub fn run(input: string) -> string { + stream::emit(input); + input; + } + "#, + ) + .expect("source should compile") + .program, + ); + let mut vm = Vm::new_shared(Arc::clone(&program)); + HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .expect("runtime hosts should bind"); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + + let items = collect_invocation_items(&mut vm, vec![Value::string("run-one")]); + assert_eq!(items.len(), 2, "first run must emit one event and complete"); + assert!( + matches!(&items[0], Ok(InvocationItem::Event(value)) if *value == Value::string("run-one")) + ); + assert!( + matches!(&items[1], Ok(InvocationItem::Complete(value)) if *value == Value::string("run-one")) + ); + + // A reset closes the run-scoped invocation state: the next run starts with + // a fresh stream and neither the old input nor the old events leak. + vm.reset_for_reuse(); + assert_eq!(vm.run().expect("root should halt again"), VmStatus::Halted); + let items_after_reset = collect_invocation_items(&mut vm, vec![Value::string("run-two")]); + assert_eq!( + items_after_reset.len(), + 2, + "reset must not leak prior events into the next run" + ); + assert!( + matches!(&items_after_reset[0], Ok(InvocationItem::Event(value)) if *value == Value::string("run-two")) + ); + assert!( + matches!(&items_after_reset[1], Ok(InvocationItem::Complete(value)) if *value == Value::string("run-two")) + ); +} +/// Fuel budgets are run-scoped: a reset clears the budget, and a new run +/// starts from its configured amount rather than inheriting leftovers. +#[test] +fn fuel_budgets_do_not_leak_between_runs() { + let program = compile_source( + r#" + fn action() -> int; + action(); + "#, + ) + .expect("source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_static_non_yielding_args_function("action", non_yielding_returns_zero); + + // A configured budget reads back as the configured amount. + vm.set_fuel(100); + assert_eq!(vm.get_fuel(), Some(100)); + + // Explicit consumption draws from the run-scoped budget. + vm.consume_fuel(30) + .expect("consumption within budget should succeed"); + assert_eq!(vm.get_fuel(), Some(70)); + + // A run charges the budget; the leftover is less than what was set. + assert_eq!(vm.run().expect("run should halt"), VmStatus::Halted); + let after_run = vm.get_fuel().expect("metering must still be active"); + assert!( + after_run < 70, + "run must consume from the active budget ({after_run} remaining)" + ); + + // Reset must clear the budget entirely (metering disabled, no leftovers). + vm.reset_for_reuse(); + assert_eq!(vm.get_fuel(), None, "reset must clear run-scoped fuel"); + + // A fresh budget starts from the configured amount, not from the prior + // run's leftover. + vm.set_fuel(200); + assert_eq!(vm.run().expect("run should halt"), VmStatus::Halted); + let fresh = vm.get_fuel().expect("metering must still be active"); + assert!( + fresh < 200, + "fresh budget must be consumed from its own amount ({fresh} remaining)" + ); + assert!( + fresh > after_run, + "fresh budget must not inherit the prior run's leftover" + ); +} + +/// The same immutable program can drive multiple VMs with independent stacks +/// and independent backend caches: one VM's reset and rerun never touches +/// another VM's execution state or cached artifacts. +#[test] +fn shared_program_backend_does_not_share_stacks_or_resources() { + let program = Arc::new( + compile_source( + r#" + fn action() -> int; + action(); + "#, + ) + .expect("source should compile") + .program, + ); + + let mut first = Vm::new_shared(Arc::clone(&program)); + let mut second = Vm::new_shared(Arc::clone(&program)); + first.bind_static_non_yielding_args_function("action", non_yielding_returns_seven); + second.bind_static_non_yielding_args_function("action", non_yielding_returns_nine); + + assert_eq!(first.run().expect("first should run"), VmStatus::Halted); + assert_eq!(second.run().expect("second should run"), VmStatus::Halted); + assert_eq!(first.stack(), &[Value::Int(7)]); + assert_eq!(second.stack(), &[Value::Int(9)]); + + // Reset + rerun on one VM must not change the other VM's stack. + first.reset_for_reuse(); + assert_eq!(first.run().expect("first should rerun"), VmStatus::Halted); + assert_eq!(first.stack(), &[Value::Int(7)]); + assert_eq!(second.stack(), &[Value::Int(9)]); +} + +/// Reset closes run-scoped state while retaining documented reusable state: +/// host bindings, backend configuration, and compiled artifacts survive, while +/// the interpreter state (ip/stack/locals) is rewound. +#[test] +fn reset_closes_run_scoped_state_and_retains_reusable_state() { + let program = compile_source( + r#" + fn action() -> int; + action(); + "#, + ) + .expect("source should compile") + .program; + let mut vm = Vm::new(program); + vm.set_regex_cache_capacity(8); + vm.bind_static_non_yielding_args_function("action", non_yielding_returns_forty_two); + + assert_eq!(vm.run().expect("first run should halt"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); + + vm.reset_for_reuse(); + + // Reusable state survives reset. + assert_eq!( + vm.regex_cache_capacity(), + 8, + "backend cache configuration is reusable across runs" + ); + assert_eq!( + vm.max_script_call_depth(), + vm::DEFAULT_MAX_SCRIPT_CALL_DEPTH, + "interpreter limits are reusable across runs" + ); + + // Run-scoped state is rewound: ip at entry, empty stack, null locals. + assert_eq!(vm.ip(), 0, "reset must rewind the instruction pointer"); + assert!(vm.stack().is_empty(), "reset must clear the stack"); + assert!( + vm.locals().iter().all(|value| *value == Value::Null), + "reset must restore null locals" + ); + + // The retained host binding still executes on the next run. + assert_eq!(vm.run().expect("second run should halt"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::Int(42)]); +} + +/// A yield caused by an explicit wait must leave the instance in a state that +/// reset can close, and a subsequent run must not inherit the wait. +#[test] +fn reset_closes_waiting_state_before_the_next_run() { + let program = compile_source( + r#" + fn action() -> int; + action(); + "#, + ) + .expect("source should compile") + .program; + let mut vm = Vm::new(program); + vm.bind_args_function("action", Box::new(PendingOneHost)); + + let status = vm.run().expect("run should yield"); + assert_eq!(status, VmStatus::Waiting(1)); + assert_eq!(vm.waiting_host_op_id(), Some(1)); + + vm.reset_for_reuse(); + assert_eq!( + vm.waiting_host_op_id(), + None, + "reset must close the pending host wait" + ); +} + +/// Regression pin for a pre-existing JIT issue that is NOT part of the +/// decomposition: a run that fails inside a host import can leave a native +/// trace/region that corrupts the *same instance's* next run after reset. +/// +/// The bug reproduces on the unmodified tree (HEAD cccdd2f + dirty host work): +/// run a program whose first host call errors, `reset_for_reuse()`, then run +/// again with valid input — the second run can fail with `StackUnderflow` +/// instead of executing. Clearing native traces between the runs makes the +/// rerun behave correctly, which isolates the cause to stale JIT state. +#[test] +#[ignore = "pre-existing JIT stale-trace replay after reset; tracked separately"] +fn reset_after_host_error_reruns_cleanly_on_the_same_instance() { + use std::sync::OnceLock; + use std::sync::atomic::{AtomicBool, Ordering}; + + static FAIL_FIRST: OnceLock = OnceLock::new(); + let fail_first = FAIL_FIRST.get_or_init(|| AtomicBool::new(true)); + fail_first.store(true, Ordering::SeqCst); + + fn flaky_action(_: &[Value]) -> Result { + if FAIL_FIRST + .get_or_init(|| AtomicBool::new(true)) + .swap(false, Ordering::SeqCst) + { + Err(vm::VmError::HostError("first call fails".to_string())) + } else { + Ok(vm::CallOutcome::Return(vm::CallReturn::one(Value::Int(42)))) + } + } + + let program = compile_source( + r#" + fn action() -> int; + pub fn run() -> int { + action(); + } + "#, + ) + .expect("source should compile") + .program; + let mut vm = vm::Vm::new(program); + vm.bind_static_non_yielding_args_function("action", flaky_action); + assert_eq!(vm.run().expect("root should halt"), VmStatus::Halted); + + let callable = vm + .resolve_exported_callable("run") + .expect("exported run callable should resolve"); + { + let mut invocation = vm + .start_invocation(callable, vec![]) + .expect("first invocation should start"); + assert!(matches!( + invocation.poll_next().expect("poll should succeed"), + InvocationPoll::Ready(Some(Err(InvocationError::Host { .. }))) + )); + } + + vm.reset_for_reuse(); + let items = collect_invocation_items(&mut vm, vec![]); + assert!( + matches!(&items[0], Ok(InvocationItem::Complete(Value::Int(42)))), + "the rerun must execute cleanly after reset, got {items:?}" + ); +} diff --git a/tests/vm/sqlite_host_tests.rs b/tests/vm/sqlite_host_tests.rs new file mode 100644 index 00000000..20be44d3 --- /dev/null +++ b/tests/vm/sqlite_host_tests.rs @@ -0,0 +1,1040 @@ +extern crate vm as rustscript_vm; + +pub mod vm { + use std::any::{Any, TypeId}; + use std::collections::HashMap; + + pub use crate::builtins::runtime::sqlite::{SqliteLimits, SqlitePolicy}; + pub use crate::rustscript_vm::{ + CallReturn, HostCallResult, HostOpId, OpCode, Program, Value, VmError, VmMap, VmResult, + }; + + use crate::builtins::runtime::cancellation::{CancellationToken, OperationRegistry}; + use crate::builtins::runtime::resource::ResourceArena; + + pub(crate) struct TestHostRuntime { + pub(crate) runtime_resources: ResourceArena, + pub(crate) runtime_operations: OperationRegistry, + host_function_states: HashMap>, + } + + impl TestHostRuntime { + pub(crate) fn set_host_function_state(&mut self, state: T) { + self.host_function_states + .insert(TypeId::of::(), Box::new(state)); + } + + pub(crate) fn host_function_state(&self) -> Option<&T> { + self.host_function_states + .get(&TypeId::of::())? + .downcast_ref() + } + + #[allow(dead_code)] + pub(crate) fn remove_host_function_state(&mut self) -> Option { + self.host_function_states + .remove(&TypeId::of::())? + .downcast::() + .ok() + .map(|state| *state) + } + } + + pub(crate) struct TestRunContext { + pub(crate) cancellation: CancellationToken, + } + + pub struct Vm { + pub(crate) host: TestHostRuntime, + pub(crate) run_ctx: TestRunContext, + } + + impl Vm { + pub fn new(_program: Program) -> Self { + Self { + host: TestHostRuntime { + runtime_resources: ResourceArena::default(), + runtime_operations: OperationRegistry::default(), + host_function_states: HashMap::new(), + }, + run_ctx: TestRunContext { + cancellation: CancellationToken::root(), + }, + } + } + } +} + +mod builtins { + pub use crate::vm::{Value, Vm, VmResult}; + + pub mod runtime { + pub use crate::vm::{HostCallResult, VmMap}; + + pub mod error { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/builtins/runtime/error.rs" + )); + } + + #[allow(dead_code)] + pub mod cancellation { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/builtins/runtime/cancellation.rs" + )); + } + + #[allow(dead_code)] + pub mod resource { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/builtins/runtime/resource.rs" + )); + } + + pub(crate) fn cancel_runtime_operation( + vm: &mut crate::vm::Vm, + op_id: cancellation::OperationId, + reason: cancellation::CancellationReason, + ) { + let payload = vm + .host + .runtime_operations + .get(op_id) + .ok() + .and_then(|operation| operation.payload()); + let _ = vm.host.runtime_operations.cancel(op_id, reason); + if let Some(payload) = payload { + let _ = close_runtime_resource(vm, payload, reason); + } + } + + pub(crate) fn close_runtime_resource( + vm: &mut crate::vm::Vm, + handle: resource::ResourceHandle, + reason: cancellation::CancellationReason, + ) -> error::RuntimeResult { + let operations = vm + .host + .runtime_operations + .operations_for_resource(handle) + .into_iter() + .map(|operation| { + let payload = operation.payload(); + (operation, payload) + }) + .collect::>(); + for (operation, _) in &operations { + operation.token().mark_cancelled(reason); + } + for (operation, _) in &operations { + let _ = vm.host.runtime_operations.cancel(operation.id(), reason); + } + for (_, payload) in operations { + if let Some(payload) = payload { + let _ = close_runtime_resource(vm, payload, reason); + } + } + vm.host.runtime_resources.close(handle, reason) + } + + pub(crate) fn cancel_operations_by_owner( + vm: &mut crate::vm::Vm, + owner: cancellation::OperationOwner, + reason: cancellation::CancellationReason, + ) { + let operations = vm.host.runtime_operations.operations_by_owner(owner); + for operation in operations { + cancel_runtime_operation(vm, operation.id(), reason); + } + } + + pub(crate) fn close_resources_by_type( + vm: &mut crate::vm::Vm, + resource_type: resource::ResourceTypeId, + reason: cancellation::CancellationReason, + ) { + let handles = vm.host.runtime_resources.handles_of_type(resource_type); + for handle in handles { + let _ = close_runtime_resource(vm, handle, reason); + } + } + + pub mod typed { + pub type VmArrayRef<'a> = &'a [crate::vm::Value]; + pub type VmMapRef<'a> = &'a crate::vm::VmMap; + } + + pub trait TestBorrowArg<'a>: Sized { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult; + } + + impl<'a> TestBorrowArg<'a> for crate::vm::Value { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + args.get(index) + .cloned() + .ok_or(crate::vm::VmError::HostError(label.to_string())) + } + } + + impl<'a> TestBorrowArg<'a> for i64 { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + match args.get(index) { + Some(crate::vm::Value::Int(value)) => Ok(*value), + _ => Err(crate::vm::VmError::HostError(label.to_string())), + } + } + } + + impl<'a> TestBorrowArg<'a> for &'a str { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + match args.get(index) { + Some(crate::vm::Value::String(value)) => Ok(value.as_str()), + _ => Err(crate::vm::VmError::HostError(label.to_string())), + } + } + } + + impl<'a> TestBorrowArg<'a> for &'a [crate::vm::Value] { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + match args.get(index) { + Some(crate::vm::Value::Array(value)) => Ok(value.as_slice()), + _ => Err(crate::vm::VmError::HostError(label.to_string())), + } + } + } + + impl<'a> TestBorrowArg<'a> for &'a crate::vm::VmMap { + fn borrow_arg( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + match args.get(index) { + Some(crate::vm::Value::Map(value)) => Ok(value.as_ref()), + _ => Err(crate::vm::VmError::HostError(label.to_string())), + } + } + } + + pub fn borrow_arg<'a, T: TestBorrowArg<'a>>( + args: &'a [crate::vm::Value], + index: usize, + label: &'static str, + ) -> crate::vm::VmResult { + T::borrow_arg(args, index, label) + } + + pub mod sqlite { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src/builtins/runtime/sqlite.rs" + )); + } + + pub mod test_api { + use std::task::{Context, Poll}; + + use super::cancellation::{ + CancellationReason, OperationId, OperationOwner, OperationStatus, + }; + use super::resource::{ResourceHandle, ResourceTypeId}; + use super::{HostCallResult, VmMap}; + use crate::vm::{CallReturn, HostOpId, Value, Vm, VmResult}; + + pub fn open(vm: &mut Vm, args: &[Value]) -> VmResult { + super::sqlite::builtin_sqlite_open(vm, args) + } + + pub fn execute(vm: &mut Vm, args: &[Value]) -> VmResult> { + super::sqlite::builtin_sqlite_execute(vm, args) + } + + pub fn query(vm: &mut Vm, args: &[Value]) -> VmResult> { + super::sqlite::builtin_sqlite_query(vm, args) + } + + pub fn transaction( + vm: &mut Vm, + args: &[Value], + ) -> VmResult>> { + super::sqlite::builtin_sqlite_transaction(vm, args) + } + + pub fn close(vm: &mut Vm, args: &[Value]) -> VmResult<()> { + super::sqlite::builtin_sqlite_close(vm, args) + } + + pub fn poll( + vm: &mut Vm, + op_id: HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + super::sqlite::poll_pending_op(vm, op_id, cx) + } + + pub fn cancel(vm: &mut Vm, op_id: HostOpId) { + let Ok(id) = OperationId::from_raw(op_id) else { + return; + }; + let payload = vm + .host + .runtime_operations + .get(id) + .ok() + .filter(|operation| operation.owner() == OperationOwner::Sqlite) + .and_then(|operation| operation.payload()); + let _ = vm + .host + .runtime_operations + .cancel(id, CancellationReason::Requested); + if let Some(payload) = payload { + let _ = vm + .host + .runtime_resources + .close(payload, CancellationReason::Requested); + } + } + + pub fn active_operation_id(vm: &Vm, resource_id: i64) -> Option { + super::sqlite::active_operation_id(vm, resource_id) + } + + pub fn has_pending(vm: &Vm, op_id: HostOpId) -> bool { + OperationId::from_raw(op_id).is_ok_and(|id| { + vm.host.runtime_operations.get(id).is_ok_and(|operation| { + operation.owner() == OperationOwner::Sqlite + && matches!(operation.status(), OperationStatus::Pending) + && operation.payload().is_some() + }) + }) + } + + pub fn close_all(vm: &mut Vm) { + let _ = vm + .host + .runtime_operations + .cancel_all(CancellationReason::VmReset); + let _ = vm + .host + .runtime_resources + .close_all(CancellationReason::VmReset); + } + + pub fn has_sqlite_operation_owner(vm: &Vm, op_id: HostOpId) -> bool { + OperationId::from_raw(op_id) + .ok() + .and_then(|id| vm.host.runtime_operations.get(id).ok()) + .map(|operation| operation.owner()) + == Some(OperationOwner::Sqlite) + } + + pub fn is_sqlite_resource(handle: i64) -> bool { + ResourceHandle::from_value(&Value::Int(handle)) + .is_ok_and(|handle| handle.resource_type() == ResourceTypeId::SQLITE_CONNECTION) + } + + pub fn insert_wrong_type_resource(vm: &mut Vm) -> i64 { + let handle = vm + .host + .runtime_resources + .insert(ResourceTypeId::IO_FILE, 7_i64) + .expect("test resource should be inserted"); + match handle.as_value() { + Value::Int(value) => value, + _ => unreachable!(), + } + } + } + } +} + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::task::{Context, Poll, Wake, Waker}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use builtins::runtime::sqlite::SqliteHostExt; +use builtins::runtime::test_api as sqlite; +use vm::{CallReturn, HostCallResult, OpCode, Program, Value, Vm, VmError}; + +struct NoopWake; + +impl Wake for NoopWake { + fn wake(self: Arc) {} +} + +fn noop_waker() -> Waker { + Waker::from(Arc::new(NoopWake)) +} + +fn new_vm() -> Vm { + Vm::new(Program::new(Vec::new(), vec![OpCode::Ret as u8])) +} + +fn temporary_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after the Unix epoch") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "rustscript-sqlite-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&root).expect("temporary SQLite root should be created"); + root +} + +fn map_value(entries: impl IntoIterator) -> Value { + Value::map( + entries + .into_iter() + .map(|(key, value)| (Value::string(key), value)) + .collect(), + ) +} + +fn field<'a>(map: &'a vm::VmMap, key: &str) -> &'a Value { + map.get(&Value::string(key)) + .unwrap_or_else(|| panic!("missing SQLite result field {key}")) +} + +fn open_options(root: &Path, path: &str, mode: &str, limits: Value) -> Value { + map_value([ + ("root", Value::string(root.to_string_lossy().into_owned())), + ("path", Value::string(path)), + ("mode", Value::string(mode)), + ("limits", limits), + ]) +} + +fn limits(entries: impl IntoIterator) -> Value { + map_value( + entries + .into_iter() + .map(|(key, value)| (key, Value::Int(value))), + ) +} + +fn empty_params() -> Value { + Value::array(Vec::new()) +} + +fn wait_pending(vm: &mut Vm, op_id: vm::HostOpId) -> Result { + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + loop { + match sqlite::poll(vm, op_id, &mut cx) { + Poll::Pending => std::thread::yield_now(), + Poll::Ready(Ok(CallReturn::None)) => return Ok(Value::Null), + Poll::Ready(Ok(CallReturn::One(value))) => return Ok(value), + Poll::Ready(Err(error)) => return Err(error), + } + } +} + +fn map_from_value(value: Value) -> vm::VmMap { + let Value::Map(map) = value else { + panic!("SQLite host result should be a map"); + }; + (*map).clone() +} + +fn host_map( + vm: &mut Vm, + result: Result, VmError>, +) -> Result { + let result = result?; + match result { + HostCallResult::Return(map) => Ok(map), + HostCallResult::Pending(op_id) => Ok(map_from_value(wait_pending(vm, op_id)?)), + } +} + +fn host_array( + vm: &mut Vm, + result: Result>, VmError>, +) -> Result, VmError> { + let result = result?; + match result { + HostCallResult::Return(values) => Ok(values), + HostCallResult::Pending(op_id) => { + let Value::Array(values) = wait_pending(vm, op_id)? else { + panic!("SQLite transaction result should be an array"); + }; + Ok((*values).clone()) + } + } +} + +fn open_db(vm: &mut Vm, options: Value) -> i64 { + if let Value::Map(options_map) = &options + && let Some(Value::String(root)) = options_map.get(&Value::string("root")) + { + vm.configure_sqlite(vm::SqlitePolicy { + database_root: Some(root.as_ref().clone()), + ..vm::SqlitePolicy::default() + }); + } + sqlite::open(vm, &[options]).expect("SQLite open should return") +} + +fn execute(vm: &mut Vm, db_id: i64, sql: &str, params: Value) -> Result { + let result = sqlite::execute(vm, &[Value::Int(db_id), Value::string(sql), params]); + host_map(vm, result) +} + +fn query( + vm: &mut Vm, + db_id: i64, + sql: &str, + params: Value, + query_limits: Value, +) -> Result { + let result = sqlite::query( + vm, + &[Value::Int(db_id), Value::string(sql), params, query_limits], + ); + host_map(vm, result) +} + +#[test] +fn sqlite_round_trip_supports_typed_values_and_ordered_transactions() { + let root = temporary_root("round-trip"); + let mut vm = new_vm(); + let db_id = open_db( + &mut vm, + open_options( + &root, + "state.db", + "read_write_create", + limits([ + ("max_rows", 128), + ("max_result_bytes", 64 * 1024), + ("max_statements", 16), + ("max_transaction_ms", 5_000), + ]), + ), + ); + + execute( + &mut vm, + db_id, + "CREATE TABLE values_table (id INTEGER PRIMARY KEY, n INTEGER, r REAL, s TEXT, b BLOB, z TEXT)", + empty_params(), + ) + .expect("table creation should succeed"); + execute( + &mut vm, + db_id, + "INSERT INTO values_table (n, r, s, b, z) VALUES (?1, ?2, ?3, ?4, ?5)", + Value::array(vec![ + Value::Int(7), + Value::Float(1.5), + Value::string("hello"), + Value::bytes(vec![0, 1, 2]), + Value::Null, + ]), + ) + .expect("typed parameter insert should succeed"); + + let rowset = query( + &mut vm, + db_id, + "SELECT n, r, s, b, z FROM values_table ORDER BY id", + empty_params(), + limits([("max_rows", 8), ("max_result_bytes", 64 * 1024)]), + ) + .expect("typed query should succeed"); + assert_eq!( + field(&rowset, "columns"), + &Value::array(vec![ + Value::string("n"), + Value::string("r"), + Value::string("s"), + Value::string("b"), + Value::string("z"), + ]) + ); + assert_eq!(field(&rowset, "truncated"), &Value::Bool(false)); + let Value::Array(rows) = field(&rowset, "rows") else { + panic!("SQLite rows should be an array"); + }; + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0], + Value::array(vec![ + Value::Int(7), + Value::Float(1.5), + Value::string("hello"), + Value::bytes(vec![0, 1, 2]), + Value::Null, + ]) + ); + + let statements = Value::array(vec![ + map_value([ + ( + "sql", + Value::string("INSERT INTO values_table (n) VALUES (?1)"), + ), + ("params", Value::array(vec![Value::Int(8)])), + ]), + map_value([ + ( + "sql", + Value::string("INSERT INTO values_table (n) VALUES (?1)"), + ), + ("params", Value::array(vec![Value::Int(9)])), + ]), + ]); + let transaction = sqlite::transaction(&mut vm, &[Value::Int(db_id), statements]) + .expect("transaction should return"); + let transaction_value = + Value::array(host_array(&mut vm, Ok(transaction)).expect("transaction should complete")); + let Value::Array(results) = transaction_value else { + panic!("transaction should return ordered results"); + }; + assert_eq!(results.len(), 2); + for result in results.iter() { + let Value::Map(result) = result else { + panic!("transaction result should be a map"); + }; + assert_eq!(field(result, "rows_affected"), &Value::Int(1)); + } + + sqlite::close(&mut vm, &[Value::Int(db_id)]).expect("SQLite close should succeed"); + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn sqlite_enforces_read_only_vm_local_ids_and_sql_safety() { + let root = temporary_root("policy"); + let mut writer_vm = new_vm(); + let db_id = open_db( + &mut writer_vm, + open_options(&root, "state.db", "read_write_create", limits([])), + ); + execute( + &mut writer_vm, + db_id, + "CREATE TABLE items (value INTEGER)", + empty_params(), + ) + .expect("table creation should succeed"); + + let mut other_vm = new_vm(); + let cross_vm_error = sqlite::execute( + &mut other_vm, + &[Value::Int(db_id), Value::string("SELECT 1"), empty_params()], + ) + .expect_err("a SQLite id must not cross VM instances"); + assert!( + cross_vm_error + .to_string() + .contains("unknown SQLite database") + ); + + let unsafe_sql = [ + "ATTACH DATABASE 'other.db' AS other", + "PRAGMA writable_schema = ON", + "SELECT load_extension('not-available')", + "CREATE TABLE first (id INTEGER); CREATE TABLE second (id INTEGER)", + ]; + for sql in unsafe_sql { + let error = sqlite::execute( + &mut writer_vm, + &[Value::Int(db_id), Value::string(sql), empty_params()], + ) + .expect_err("unsafe SQL should be rejected before execution"); + assert!( + error.to_string().contains("not allowed") + || error.to_string().contains("multiple statements") + || error.to_string().contains("disabled"), + "unexpected SQLite policy error: {error}" + ); + } + + let mut read_only_vm = new_vm(); + let read_only_id = open_db( + &mut read_only_vm, + open_options(&root, "state.db", "read_only", limits([])), + ); + let read_only_result = sqlite::execute( + &mut read_only_vm, + &[ + Value::Int(read_only_id), + Value::string("INSERT INTO items (value) VALUES (1)"), + empty_params(), + ], + ); + let read_only_error = host_map(&mut read_only_vm, read_only_result) + .expect_err("read-only SQLite handles must reject writes"); + assert!( + read_only_error.to_string().contains("ReadOnly") + || read_only_error.to_string().contains("readonly") + || read_only_error.to_string().contains("read-only"), + "unexpected read-only error: {read_only_error}" + ); + + sqlite::close_all(&mut writer_vm); + sqlite::close_all(&mut read_only_vm); + sqlite::close_all(&mut other_vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn sqlite_query_reports_row_and_result_byte_truncation() { + let root = temporary_root("limits"); + let mut vm = new_vm(); + let db_id = open_db( + &mut vm, + open_options( + &root, + "state.db", + "read_write_create", + limits([("max_rows", 32), ("max_result_bytes", 32)]), + ), + ); + execute( + &mut vm, + db_id, + "CREATE TABLE items (value TEXT)", + empty_params(), + ) + .expect("table creation should succeed"); + for value in ["one", "two", "three"] { + execute( + &mut vm, + db_id, + "INSERT INTO items (value) VALUES (?1)", + Value::array(vec![Value::string(value)]), + ) + .expect("row insertion should succeed"); + } + + let row_limited = query( + &mut vm, + db_id, + "SELECT value FROM items ORDER BY rowid", + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ) + .expect("limited query should succeed"); + assert_eq!(field(&row_limited, "truncated"), &Value::Bool(true)); + let Value::Array(rows) = field(&row_limited, "rows") else { + panic!("SQLite rows should be an array"); + }; + assert_eq!(rows.len(), 1); + + let byte_limited = query( + &mut vm, + db_id, + "SELECT value FROM items ORDER BY rowid", + empty_params(), + limits([("max_rows", 32), ("max_result_bytes", 8)]), + ) + .expect("byte-limited query should succeed"); + assert_eq!(field(&byte_limited, "truncated"), &Value::Bool(true)); + + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn sqlite_pending_operations_can_be_cancelled_and_cleaned_up() { + let root = temporary_root("cancel"); + let mut vm = new_vm(); + let db_id = open_db( + &mut vm, + open_options( + &root, + "state.db", + "read_write_create", + limits([ + ("max_transaction_ms", 10_000), + ("max_result_bytes", 64 * 1024), + ]), + ), + ); + let pending = sqlite::query( + &mut vm, + &[ + Value::Int(db_id), + Value::string( + "WITH RECURSIVE numbers(value) AS (\ + SELECT 1 UNION ALL SELECT value + 1 FROM numbers LIMIT 100000000\ + ) SELECT sum(value) FROM numbers", + ), + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ], + ) + .expect("long SQLite query should be scheduled"); + let HostCallResult::Pending(op_id) = pending else { + panic!("long SQLite query should return a pending operation"); + }; + assert!(sqlite::has_pending(&vm, op_id)); + sqlite::cancel(&mut vm, op_id); + assert!(!sqlite::has_pending(&vm, op_id)); + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn cancelling_queued_sqlite_operation_does_not_interrupt_active_sibling() { + let root = temporary_root("queued_cancel"); + let mut vm = new_vm(); + let db_id = open_db( + &mut vm, + open_options( + &root, + "state.db", + "read_write_create", + limits([ + ("max_transaction_ms", 10_000), + ("max_result_bytes", 64 * 1024), + ]), + ), + ); + let active = sqlite::query( + &mut vm, + &[ + Value::Int(db_id), + Value::string( + "WITH RECURSIVE numbers(value) AS (\ + SELECT 1 UNION ALL SELECT value + 1 FROM numbers LIMIT 2000000\ + ) SELECT sum(value) FROM numbers", + ), + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ], + ) + .expect("active query should schedule"); + let HostCallResult::Pending(active_id) = active else { + panic!("active query should be pending"); + }; + let wait_deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + while sqlite::active_operation_id(&vm, db_id) != Some(active_id) { + assert!( + std::time::Instant::now() < wait_deadline, + "active query should enter SQLite execution" + ); + std::thread::yield_now(); + } + + let queued = sqlite::query( + &mut vm, + &[ + Value::Int(db_id), + Value::string("SELECT 42"), + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ], + ) + .expect("queued query should schedule"); + let HostCallResult::Pending(queued_id) = queued else { + panic!("queued query should be pending"); + }; + sqlite::cancel(&mut vm, queued_id); + assert_eq!(sqlite::active_operation_id(&vm, db_id), Some(active_id)); + + wait_pending(&mut vm, active_id).expect("active sibling should complete successfully"); + assert!(!sqlite::has_pending(&vm, active_id)); + assert!(!sqlite::has_pending(&vm, queued_id)); + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +fn assert_sqlite_shutdown_cancels_all_siblings(close_all: bool) { + let root = temporary_root(if close_all { + "cancel_all_two_phase" + } else { + "close_two_phase" + }); + let mut vm = new_vm(); + let options = open_options( + &root, + "state.db", + "read_write_create", + limits([ + ("max_transaction_ms", 10_000), + ("max_result_bytes", 64 * 1024), + ]), + ); + let db_id = open_db(&mut vm, options.clone()); + execute( + &mut vm, + db_id, + "CREATE TABLE items (value INTEGER)", + empty_params(), + ) + .expect("table creation should succeed"); + + let active = sqlite::query( + &mut vm, + &[ + Value::Int(db_id), + Value::string( + "WITH RECURSIVE numbers(value) AS (\ + SELECT 1 UNION ALL SELECT value + 1 FROM numbers LIMIT 2000000\ + ) SELECT sum(value) FROM numbers", + ), + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ], + ) + .expect("active query should schedule"); + let HostCallResult::Pending(active_id) = active else { + panic!("active query should be pending"); + }; + let wait_deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + while sqlite::active_operation_id(&vm, db_id) != Some(active_id) { + assert!(std::time::Instant::now() < wait_deadline); + std::thread::yield_now(); + } + + let queued = sqlite::execute( + &mut vm, + &[ + Value::Int(db_id), + Value::string("INSERT INTO items (value) VALUES (42)"), + empty_params(), + ], + ) + .expect("queued insert should schedule"); + let HostCallResult::Pending(queued_id) = queued else { + panic!("queued insert should be pending"); + }; + + if close_all { + sqlite::close_all(&mut vm); + } else { + sqlite::close(&mut vm, &[Value::Int(db_id)]).expect("close should succeed"); + } + assert!(!sqlite::has_pending(&vm, active_id)); + assert!(!sqlite::has_pending(&vm, queued_id)); + + let reopened = open_db(&mut vm, options); + let result = query( + &mut vm, + reopened, + "SELECT count(*) AS count FROM items", + empty_params(), + limits([("max_rows", 1), ("max_result_bytes", 64 * 1024)]), + ) + .expect("count query should succeed"); + let Value::Array(rows) = field(&result, "rows") else { + panic!("rows should be an array"); + }; + assert_eq!(rows[0], Value::array(vec![Value::Int(0)])); + + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn sqlite_close_cancels_all_siblings_before_waiting_for_workers() { + assert_sqlite_shutdown_cancels_all_siblings(false); +} + +#[test] +fn sqlite_cancel_all_broadcasts_before_waiting_for_workers() { + assert_sqlite_shutdown_cancels_all_siblings(true); +} + +#[test] +fn sqlite_uses_typed_generation_checked_resource_handles() { + let root = temporary_root("resource_handles"); + let mut vm = new_vm(); + let first = open_db( + &mut vm, + open_options(&root, "handles.db", "read_write_create", limits([])), + ); + assert!(sqlite::is_sqlite_resource(first)); + + sqlite::close(&mut vm, &[Value::Int(first)]).expect("first handle should close"); + let second = open_db( + &mut vm, + open_options(&root, "handles.db", "read_write_create", limits([])), + ); + assert_ne!( + first, second, + "slot reuse must advance the handle generation" + ); + + let stale = sqlite::execute( + &mut vm, + &[Value::Int(first), Value::string("SELECT 1"), empty_params()], + ) + .expect_err("a closed generation must stay invalid after slot reuse"); + assert!(stale.to_string().contains("unknown SQLite database")); + + let wrong_type = sqlite::insert_wrong_type_resource(&mut vm); + let wrong_type_error = sqlite::execute( + &mut vm, + &[ + Value::Int(wrong_type), + Value::string("SELECT 1"), + empty_params(), + ], + ) + .expect_err("a handle from another resource type must be rejected"); + assert!(wrong_type_error.to_string().contains("wrong resource type")); + + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} + +#[test] +fn sqlite_pending_work_is_registered_with_the_shared_owner() { + let root = temporary_root("operation_owner"); + let mut vm = new_vm(); + let db_id = open_db( + &mut vm, + open_options(&root, "operations.db", "read_write_create", limits([])), + ); + + let operation = sqlite::execute( + &mut vm, + &[ + Value::Int(db_id), + Value::string("CREATE TABLE items(id INTEGER PRIMARY KEY)"), + empty_params(), + ], + ) + .expect("execute should schedule"); + let HostCallResult::Pending(op_id) = operation else { + panic!("execute should return a pending operation"); + }; + assert!(sqlite::has_sqlite_operation_owner(&vm, op_id)); + let _ = wait_pending(&mut vm, op_id).expect("shared operation should complete"); + assert!(!sqlite::has_pending(&vm, op_id)); + + sqlite::close_all(&mut vm); + fs::remove_dir_all(root).expect("temporary SQLite root should be removed"); +} diff --git a/tests/vm/vm_async_runtime_tests.rs b/tests/vm/vm_async_runtime_tests.rs index b58f8fe8..7fb957ad 100644 --- a/tests/vm/vm_async_runtime_tests.rs +++ b/tests/vm/vm_async_runtime_tests.rs @@ -12,8 +12,8 @@ use std::{ use tokio::sync::oneshot; use vm::{ - BytecodeBuilder, CallOutcome, HostAsyncBridge, HostFunction, HostImport, HostOpId, Program, - Value, ValueType, Vm, VmError, VmStatus, + BytecodeBuilder, CallOutcome, CancellationReason, HostAsyncBridge, HostFunction, HostImport, + HostOpId, Program, Value, ValueType, Vm, VmError, VmStatus, }; type AsyncHostResult = Result; @@ -22,6 +22,7 @@ type SharedAsyncOps = Arc>; #[derive(Default)] struct TestAsyncOps { pending: HashMap>, + cancellations: Vec<(HostOpId, CancellationReason)>, } impl TestAsyncOps { @@ -96,6 +97,12 @@ impl HostAsyncBridge for TestAsyncBridge { .pending .remove(&op_id); } + + fn cancel_op_with_reason(&mut self, op_id: HostOpId, reason: CancellationReason) { + let mut ops = self.ops.lock().expect("test async ops lock poisoned"); + ops.pending.remove(&op_id); + ops.cancellations.push((op_id, reason)); + } } struct AsyncAddOneFunction { @@ -134,6 +141,14 @@ impl HostFunction for AsyncAddOneFunction { } } +struct InvalidPendingFunction; + +impl HostFunction for InvalidPendingFunction { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> Result { + Ok(CallOutcome::Pending(0)) + } +} + fn build_async_import_program(input: i64) -> Program { let constants = vec![Value::Int(input)]; let imports = vec![HostImport { @@ -226,8 +241,61 @@ async fn reset_cancels_pending_host_bridge_operation() { )); assert_eq!(ops.lock().unwrap().pending.len(), 1); vm.reset_for_reuse(); - assert_eq!(ops.lock().unwrap().pending.len(), 0); + let ops = ops.lock().unwrap(); + assert_eq!(ops.pending.len(), 0); + assert_eq!(ops.cancellations.len(), 1); + assert_eq!(ops.cancellations[0].1, CancellationReason::VmReset); + drop(ops); + assert_eq!(vm.waiting_host_op_id(), None); +} + +#[test] +fn rejected_pending_result_cancels_bridge_owned_work() { + let ops = Arc::new(Mutex::new(TestAsyncOps::default())); + let mut vm = Vm::new(build_async_import_program(41)); + vm.bind_function("edge::async_add_one", Box::new(InvalidPendingFunction)); + vm.set_async_bridge(Box::new(TestAsyncBridge::new(ops.clone()))); + + vm.run() + .expect_err("zero host operation id should be rejected"); + assert_eq!( + ops.lock().unwrap().cancellations, + vec![(0, CancellationReason::ResourceClosed)] + ); + assert_eq!(vm.waiting_host_op_id(), None); +} + +#[tokio::test(flavor = "current_thread")] +async fn user_cancellation_reaches_host_bridge_and_clears_waiting_state() { + let ops = Arc::new(Mutex::new(TestAsyncOps::default())); + let calls = Arc::new(AtomicUsize::new(0)); + let mut vm = Vm::new(build_async_import_program(41)); + vm.bind_function( + "edge::async_add_one", + Box::new(AsyncAddOneFunction::new( + ops.clone(), + calls, + Duration::from_secs(60), + )), + ); + vm.set_async_bridge(Box::new(TestAsyncBridge::new(ops.clone()))); + + let op_id = match vm.run().expect("pending call") { + VmStatus::Waiting(op_id) => op_id, + status => panic!("expected waiting status, got {status:?}"), + }; + let error = vm + .wait_for_host_op_blocking_with_cancel(|| true) + .expect_err("user cancellation should stop the wait"); + assert!(error.to_string().contains("cancelled")); + let ops = ops.lock().unwrap(); + assert_eq!(ops.pending.len(), 0); + assert_eq!( + ops.cancellations, + vec![(op_id, CancellationReason::Requested)] + ); assert_eq!(vm.waiting_host_op_id(), None); + assert!(op_id > 0); } #[tokio::test(flavor = "current_thread")] diff --git a/tests/vm/vm_runtime_tests.rs b/tests/vm/vm_runtime_tests.rs index f9c6fcb4..92536a81 100644 --- a/tests/vm/vm_runtime_tests.rs +++ b/tests/vm/vm_runtime_tests.rs @@ -23,6 +23,151 @@ fn non_yielding_returns_bool(_: &[Value]) -> Result { Ok(CallOutcome::Return(vm::CallReturn::one(Value::Bool(true)))) } +fn returns_registered_value(_: &[Value]) -> Result { + Ok(CallOutcome::Return(vm::CallReturn::one(Value::Int(42)))) +} + +#[test] +fn empty_registry_allows_functions_registered_by_the_embedder() { + let compiled = + compile_source("fn action() -> int; action();").expect("host call source should compile"); + let mut registry = HostFunctionRegistry::empty(); + registry.register_static_args("action", 0, returns_registered_value); + let mut vm = Vm::new(compiled.program); + registry + .bind_vm_cached(&mut vm) + .expect("custom registry should bind its registered import"); + + assert_eq!( + vm.run().expect("custom host call should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(42)]); +} + +#[test] +fn explicit_capability_profile_authorizes_host_imports_during_preflight() { + let program = compile_source("fn action() -> int; action();") + .expect("host call source should compile") + .program; + let mut registry = HostFunctionRegistry::empty(); + registry.register_static_args("action", 0, returns_registered_value); + registry.set_capability_profile(CapabilityProfile::deny_all()); + + let mut denied = Vm::new(program.clone()); + let error = registry + .bind_vm_cached(&mut denied) + .expect_err("deny-all profile must reject the host import during binding"); + assert!(error.to_string().contains("capability")); + + let mut allowed_registry = HostFunctionRegistry::empty(); + allowed_registry.set_capability_profile( + CapabilityProfile::builder() + .allow_host_import("action") + .build(), + ); + allowed_registry.register_static_args("action", 0, returns_registered_value); + let mut allowed = Vm::new(program); + allowed_registry + .bind_vm_cached(&mut allowed) + .expect("allowed host import should bind"); + assert_eq!( + allowed.run().expect("host call should run"), + VmStatus::Halted + ); + assert_eq!(allowed.stack(), &[Value::Int(42)]); +} + +#[test] +fn empty_registry_preserves_default_builtin_capabilities() { + let compiled = compile_source("use bytes; bytes::from_array_u8([1, 2, 3]);") + .expect("bytes source should compile"); + let mut vm = Vm::new(compiled.program); + HostFunctionRegistry::empty() + .bind_vm_cached(&mut vm) + .expect("empty registry should bind builtin calls"); + + assert_eq!(vm.run().expect("builtin call should run"), VmStatus::Halted); + assert_eq!(vm.stack(), &[Value::bytes(vec![1, 2, 3])]); +} + +#[test] +fn explicit_capability_profile_authorizes_builtin_calls_during_preflight() { + let program = compile_source("use bytes; bytes::from_array_u8([1, 2, 3]);") + .expect("bytes source should compile") + .program; + let mut registry = HostFunctionRegistry::empty(); + registry.set_capability_profile(CapabilityProfile::deny_all()); + + let mut denied = Vm::new(program.clone()); + let error = registry + .bind_vm_cached(&mut denied) + .expect_err("deny-all profile must reject builtin calls during binding"); + assert!(error.to_string().contains("capability")); + + registry.set_capability_profile( + CapabilityProfile::builder() + .allow_builtin(vm::BuiltinFunction::BytesFromArrayU8) + .build(), + ); + let mut allowed = Vm::new(program); + registry + .bind_vm_cached(&mut allowed) + .expect("allowed builtin should bind"); + assert_eq!( + allowed.run().expect("builtin call should run"), + VmStatus::Halted + ); + assert_eq!(allowed.stack(), &[Value::bytes(vec![1, 2, 3])]); +} + +#[test] +fn explicit_capability_profile_rejects_builtin_callable_metadata_during_preflight() { + let mut program = Program::new(Vec::new(), vec![OpCode::Ret as u8]); + program.callable_prototypes.push(vm::CallablePrototype { + kind: vm::CallableKind::HostFunction, + target: vm::CallableTarget::HostImport(vm::BuiltinFunction::BytesFromArrayU8.call_index()), + arity: 1, + frame_local_count: 0, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }); + let mut vm = Vm::new(program); + let mut registry = HostFunctionRegistry::empty(); + registry.set_capability_profile(CapabilityProfile::deny_all()); + + let error = registry + .bind_vm_cached(&mut vm) + .expect_err("builtin callable metadata must be authorized during binding"); + assert!(error.to_string().contains("capability")); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn restricted_builtin_capabilities_are_rejected_before_interpreter_or_aot_execution() { + let source = "use bytes; bytes::from_array_u8([1, 2, 3]);"; + let program = compile_source(source) + .expect("bytes source should compile") + .program; + + let mut interpreter = Vm::new(program.clone()); + let interpreter_error = HostFunctionRegistry::restricted() + .bind_vm_cached(&mut interpreter) + .expect_err("restricted profile should reject before interpreter execution"); + + let mut aot = Vm::new(program); + aot.compile_aot().expect("AOT compile should succeed"); + let aot_error = HostFunctionRegistry::restricted() + .bind_vm_cached(&mut aot) + .expect_err("restricted profile should reject before AOT execution"); + assert_eq!(interpreter_error.to_string(), aot_error.to_string()); + assert!(interpreter_error.to_string().contains("capability")); +} + #[test] fn non_yielding_args_return_type_contract_is_enforced_before_jit_compilation() { let compiled = @@ -324,6 +469,47 @@ fn namespaced_builtin_io_call_can_be_overridden_by_host_binding() { assert_eq!(vm.stack(), &[Value::Bool(false)]); } +#[test] +fn builtin_override_does_not_bypass_restricted_capability_profile() { + struct ExistsOverride; + + impl HostFunction for ExistsOverride { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> Result { + Ok(CallOutcome::Return(vec![Value::Bool(false)].into())) + } + } + + let program = compile_source( + r#" + use io; + io::exists("request_body"); + "#, + ) + .expect("source should compile") + .program; + + let mut denied = Vm::new(program.clone()); + let error = HostFunctionRegistry::restricted() + .bind_vm_cached(&mut denied) + .expect_err("restricted profile should reject before override installation"); + assert!(error.to_string().contains("capability")); + + let mut allowed_registry = HostFunctionRegistry::restricted(); + allowed_registry + .allow_builtin("io::exists") + .expect("IO builtin should be known"); + let mut allowed = Vm::new(program); + allowed_registry + .bind_vm_cached(&mut allowed) + .expect("allowlisted registry should bind"); + allowed.bind_function("io::exists", Box::new(ExistsOverride)); + assert_eq!( + allowed.run().expect("override should run"), + VmStatus::Halted + ); + assert_eq!(allowed.stack(), &[Value::Bool(false)]); +} + #[test] fn namespaced_builtin_json_encode_call_can_be_overridden_by_host_binding() { struct JsonEncodeOverride; diff --git a/tests/vm_tests.rs b/tests/vm_tests.rs index 47a3a179..176b216c 100644 --- a/tests/vm_tests.rs +++ b/tests/vm_tests.rs @@ -1,6 +1,9 @@ #![cfg(feature = "runtime")] #![allow(clippy::duplicate_mod)] +#[path = "vm/ownership_tests.rs"] +mod ownership_tests; + #[path = "vm/drop_contract_tests.rs"] mod drop_contract_tests; @@ -15,3 +18,6 @@ mod vm_async_runtime_tests; #[path = "vm/vm_runtime_tests.rs"] mod vm_runtime_tests; + +#[path = "vm/call_script_tests.rs"] +mod call_script_tests; diff --git a/tests/wire/wire_tests.rs b/tests/wire/wire_tests.rs index 9cd2a0f7..a48ffd92 100644 --- a/tests/wire/wire_tests.rs +++ b/tests/wire/wire_tests.rs @@ -1,10 +1,11 @@ use std::collections::HashMap; use vm::{ - ArgInfo, Assembler, BuiltinFunction, BytecodeBuilder, DebugFunction, DebugInfo, - DisassembleOptions, HostImport, LineInfo, LocalInfo, Program, TypeMap, ValidationError, Value, - ValueType, WireError, builtin_call_index, decode_program, disassemble_vmbc, - disassemble_vmbc_with_options, encode_program, infer_local_count, validate_program, + ArgInfo, Assembler, BuiltinFunction, BytecodeBuilder, CallableKind, CallablePrototype, + CallableTarget, DebugFunction, DebugInfo, DisassembleOptions, HostImport, LineInfo, LocalInfo, + Program, ScriptFunction, TypeMap, ValidationError, Value, ValueType, WireError, + builtin_call_index, decode_program, disassemble_vmbc, disassemble_vmbc_with_options, + encode_program, infer_local_count, validate_program, }; #[test] @@ -55,7 +56,7 @@ fn wire_roundtrip_preserves_constants_and_code() { }); let encoded = encode_program(&program).expect("encode should succeed"); - assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 11); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 12); let decoded = decode_program(&encoded).expect("decode should succeed"); assert_eq!(decoded.constants, program.constants); @@ -117,6 +118,13 @@ fn decode_rejects_invalid_magic_version_and_truncation() { Err(WireError::UnsupportedVersion(10)) )); + let mut v11_version = encoded.clone(); + v11_version[4..6].copy_from_slice(&11u16.to_le_bytes()); + assert!(matches!( + decode_program(&v11_version), + Err(WireError::UnsupportedVersion(11)) + )); + let truncated = &encoded[..encoded.len() - 1]; assert!(matches!( decode_program(truncated), @@ -172,7 +180,7 @@ fn validate_accepts_known_good_program() { } #[test] -fn callable_metadata_roundtrips_vmbc_v11() { +fn callable_metadata_roundtrips_vmbc_v12() { let compiled = vm::compile_source_for_repl( r#" fn add_one(value: int) -> int { value + 1 } @@ -481,3 +489,251 @@ fn literal_string_builtin_indices_are_appended_and_publicly_resolved() { assert_eq!(BuiltinFunction::StringLowerAscii.call_index(), first + 2); assert_eq!(BuiltinFunction::StringSplitLiteral.call_index(), first - 1); } + +// --------------------------------------------------------------------------- +// Milestone 6: CallScript wire support (VMBC V12) +// --------------------------------------------------------------------------- + +#[test] +fn call_script_roundtrips_validation_and_disassembly() { + let mut code = vec![0x1A]; + code.extend_from_slice(&7u32.to_le_bytes()); + code.push(2); + code.push(vm::OpCode::Ret as u8); + // The V12 validator resolves the prototype id against the callable + // metadata, so the fixture carries a matching prototype (id 7, arity 2, + // script-function target) plus one script function boundary. + let program = Program::new(vec![], code).with_callable_metadata( + vec![ScriptFunction { + entry_ip: 6, + end_ip: 7, + }], + (0..8) + .map(|_| CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 2, + frame_local_count: 2, + parameter_slots: vec![0, 1], + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }) + .collect(), + Vec::new(), + Vec::new(), + ); + + validate_program(&program, 0).expect("callscript should validate structurally"); + let bytes = encode_program(&program).expect("callscript should encode"); + let decoded = decode_program(&bytes).expect("callscript should decode"); + assert_eq!(decoded.code, program.code); + validate_program(&decoded, 0).expect("decoded callscript should validate"); + assert!(disassemble_vmbc(&bytes).unwrap().contains("callscript 7 2")); +} + +#[test] +fn call_script_text_assembler_parses_prototype_and_argc() { + let program = + vm::assemble("callscript 7 2\nret\n").expect("text assembler should parse callscript"); + let mut expected = vec![0x1A]; + expected.extend_from_slice(&7u32.to_le_bytes()); + expected.push(2); + expected.push(vm::OpCode::Ret as u8); + assert_eq!(program.code, expected); +} + +#[test] +fn validate_rejects_truncated_call_script_operands() { + // No operand bytes at all. + let missing_all = Program::new(vec![], vec![0x1A]); + assert!(matches!( + validate_program(&missing_all, 0), + Err(ValidationError::TruncatedOperand { + expected_bytes: 5, + .. + }) + )); + // Four of the five operand bytes present: the u32 prototype id without + // the trailing argc byte. + let mut missing_argc = vec![0x1A]; + missing_argc.extend_from_slice(&3u32.to_le_bytes()); + let missing_argc = Program::new(vec![], missing_argc); + assert!(matches!( + validate_program(&missing_argc, 0), + Err(ValidationError::TruncatedOperand { + expected_bytes: 5, + .. + }) + )); +} + +#[test] +fn validate_rejects_out_of_range_call_script_prototype() { + // CallScript(7, 2) with no callable prototypes at all: the target id is + // out of range and must be rejected deterministically at validation + // time instead of surfacing later as a runtime VM error. + let mut code = vec![0x1A]; + code.extend_from_slice(&7u32.to_le_bytes()); + code.push(2); + code.push(vm::OpCode::Ret as u8); + let no_prototypes = Program::new(vec![], code); + assert!(matches!( + validate_program(&no_prototypes, 0), + Err(ValidationError::InvalidCallScriptTarget { + offset: 0, + prototype_id: 7 + }) + )); + + // One prototype exists (id 0) but the call targets id 1. + let mut code = vec![0x1A]; + code.extend_from_slice(&1u32.to_le_bytes()); + code.push(0); + code.push(vm::OpCode::Ret as u8); + let out_of_range = Program::new(vec![], code).with_callable_metadata( + vec![ScriptFunction { + entry_ip: 6, + end_ip: 7, + }], + vec![CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 0, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }], + Vec::new(), + Vec::new(), + ); + assert!(matches!( + validate_program(&out_of_range, 0), + Err(ValidationError::InvalidCallScriptTarget { + offset: 0, + prototype_id: 1 + }) + )); +} + +#[test] +fn validate_rejects_call_script_arity_mismatch() { + // Prototype 0 declares arity 1 but the call passes 2 operands. + let mut code = vec![0x1A]; + code.extend_from_slice(&0u32.to_le_bytes()); + code.push(2); + code.push(vm::OpCode::Ret as u8); + let program = Program::new(vec![], code).with_callable_metadata( + vec![ScriptFunction { + entry_ip: 6, + end_ip: 7, + }], + vec![CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 1, + frame_local_count: 1, + parameter_slots: vec![0], + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }], + Vec::new(), + Vec::new(), + ); + assert!(matches!( + validate_program(&program, 0), + Err(ValidationError::InvalidCallScriptArity { + offset: 0, + prototype_id: 0, + expected: 1, + got: 2 + }) + )); +} + +#[test] +fn validate_rejects_call_script_targeting_host_import_prototype() { + // `CallScript` is a static script-function call: a host-import + // prototype is not a valid target. The VM rejects the same program + // shape with the typed `InvalidCallablePrototype` runtime error, so + // VMBC must reject it deterministically at validation time too. + let mut code = vec![0x1A]; + code.extend_from_slice(&0u32.to_le_bytes()); + code.push(1); + code.push(vm::OpCode::Ret as u8); + let program = Program::with_imports_and_debug( + Vec::new(), + code, + vec![HostImport { + name: "host_fn".to_string(), + arity: 1, + return_type: ValueType::Unknown, + }], + None, + ) + .with_callable_metadata( + Vec::new(), + vec![CallablePrototype { + kind: CallableKind::HostFunction, + target: CallableTarget::HostImport(0), + arity: 1, + frame_local_count: 1, + parameter_slots: vec![0], + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + }], + Vec::new(), + Vec::new(), + ); + assert!(matches!( + validate_program(&program, 0), + Err(ValidationError::InvalidCallScriptTarget { + offset: 0, + prototype_id: 0 + }) + )); +} + +#[test] +fn call_script_wire_version_is_v12_and_rejects_v11() { + let program = Program::new(vec![], vec![vm::OpCode::Ret as u8]); + let encoded = encode_program(&program).expect("encode should succeed"); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 12); + + let mut old = encoded.clone(); + old[4..6].copy_from_slice(&11u16.to_le_bytes()); + assert!(matches!( + decode_program(&old), + Err(WireError::UnsupportedVersion(11)) + )); +} + +#[test] +fn call_script_no_script_program_code_bytes_unchanged_by_version_bump() { + // The V12 bump must not alter instruction bytes for programs without + // script calls: encode a plain arithmetic program and verify the + // embedded code section is exactly the assembler output. + let mut bc = BytecodeBuilder::new(); + bc.ldc(0); + bc.ldc(1); + bc.add(); + bc.ret(); + let program = Program::new(vec![Value::Int(1), Value::Int(2)], bc.finish()); + let encoded = encode_program(&program).expect("encode should succeed"); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 12); + let decoded = decode_program(&encoded).expect("decode should succeed"); + assert_eq!(decoded.code, program.code); + assert_eq!(decoded.constants, program.constants); +}