diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e2c703a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +.cache +target diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09afc66..782ddea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: php-version: ${{ matrix.php }} env: phpts: nts - - uses: dtolnay/rust-toolchain@1.96 + - uses: dtolnay/rust-toolchain@1.99.1 # ubuntu-22.04 (kept for glibc 2.35 portability) defaults to clang 14, # whose emmintrin.h trips a bindgen SSE2 intrinsics bug when parsing the # PHP headers on x86_64. clang 15+ fixes it; aarch64 never hits it. @@ -162,7 +162,7 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - - uses: dtolnay/rust-toolchain@1.96 + - uses: dtolnay/rust-toolchain@1.99.1 - run: cargo build --release - name: Stage artifact run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 992c801..09b2e0e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: phpts: nts - name: Set up Rust - uses: dtolnay/rust-toolchain@1.96 + uses: dtolnay/rust-toolchain@1.99.1 with: components: rustfmt @@ -54,6 +54,7 @@ jobs: - name: PHP integration suite run: | + composer install --no-interaction --prefer-dist EXT="$(pwd)/target/debug/libphp_quickjs.so" php -d extension="$EXT" -r 'exit(class_exists("QuickJS") ? 0 : 1);' \ || { echo "extension failed to load"; exit 1; } diff --git a/.gitignore b/.gitignore index 4fd98b0..d40bf14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /target +/vendor **/*.rs.bk *.so +.* diff --git a/Cargo.lock b/Cargo.lock index 1dcf29b..fbf0a81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.1" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", @@ -21,9 +21,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -36,9 +36,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "autocfg" @@ -48,9 +48,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "base64-simd" @@ -68,11 +68,29 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.73.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "787ef8ef523575546b106a58213d6e6b06198a05c2f757258c68a74273670cfa" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "log", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 3.0.9", +] + [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bitvec" @@ -110,9 +128,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bzip2" @@ -125,9 +143,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.3" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ "serde_core", ] @@ -143,15 +161,16 @@ dependencies = [ [[package]] name = "cargo_metadata" -version = "0.14.2" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" +checksum = "eee4243f1f26fc7a42710e7439c149e2b10b05472f88090acce52632f231a73a" dependencies = [ "camino", "cargo-platform", "semver", "serde", "serde_json", + "thiserror 1.0.69", ] [[package]] @@ -165,14 +184,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", + "shlex", ] [[package]] @@ -186,9 +205,15 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "cipher" @@ -202,9 +227,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -223,21 +248,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror", + "thiserror 2.0.21", ] [[package]] name = "compact_str" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" dependencies = [ "castaway", "cfg-if", "itoa", - "rustversion", - "ryu", "static_assertions", + "zmij", ] [[package]] @@ -248,15 +272,18 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "constant_time_eq" -version = "0.4.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] [[package]] name = "convert_case" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +checksum = "1af709f1f33454bf52eadfc8c78b3b9ef9cb26fb54d16dc9cd9a7299f899fd1b" dependencies = [ "unicode-segmentation", ] @@ -291,18 +318,18 @@ checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -318,18 +345,18 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" dependencies = [ "cmov", ] [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -337,33 +364,33 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn", + "syn 3.0.9", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn", + "syn 3.0.9", ] [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "debugid" @@ -383,9 +410,9 @@ checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" [[package]] name = "der" -version = "0.8.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" dependencies = [ "pem-rfc7468", "zeroize", @@ -393,9 +420,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "digest" @@ -412,13 +439,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -429,9 +456,9 @@ checksum = "fd8e701084c37e7ef62d3f9e453b618130cbc0ef3573847785952a3ac3f746bf" [[package]] name = "either" -version = "1.16.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "embedded-io" @@ -447,9 +474,9 @@ checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -462,56 +489,35 @@ dependencies = [ ] [[package]] -name = "error-chain" -version = "0.12.4" +name = "escape-simd" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" -dependencies = [ - "version_check", -] +checksum = "f0440157235aa4badd45ea073af8de2ba44ffead74bf8a7833b215533c36f08e" [[package]] name = "ext-php-rs" -version = "0.15.15" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe62f25cd053f5f95dc7bbf94e5b41818ea3cf39b36cc25de8ca6b4de68a0eb" +checksum = "f1279870fe6585ac0ba42ed6af0c5c5477b2b7f44dc073d4a6586a5b4a9c4aa0" dependencies = [ "anyhow", + "bindgen", "bitflags", "cc", "cfg-if", - "ext-php-rs-bindgen", "ext-php-rs-build", "ext-php-rs-derive", + "ext-php-rs-introspection", "inventory", "native-tls", "once_cell", "parking_lot", "skeptic", + "thiserror 2.0.21", "ureq", "zip", ] -[[package]] -name = "ext-php-rs-bindgen" -version = "0.72.1-extphprs.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32ceb73da3b8b18a3424765dd6926dbc29464a0a7bc31ab491600bfa02adc4bc" -dependencies = [ - "bitflags", - "cexpr", - "clang-sys", - "itertools", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex 1.3.0", - "syn", -] - [[package]] name = "ext-php-rs-build" version = "0.1.1" @@ -523,53 +529,47 @@ dependencies = [ [[package]] name = "ext-php-rs-derive" -version = "0.11.14" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f57a075f878ad51bcc56745fb21efe601429a509204f8a385bbe526566597320" +checksum = "d31fcc898471e5ab9f4495a8772408f1219f2caa7e1dd36244598a76c0a868cf" dependencies = [ "convert_case", "darling", "itertools", "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] +[[package]] +name = "ext-php-rs-introspection" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa29f09b08e644d5dc1e76a318f758afc9dccbe92ccf84b4778e0433a78eecc" + [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", "miniz_oxide", "zlib-rs", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -608,21 +608,21 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-task", @@ -630,18 +630,6 @@ dependencies = [ "slab", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -651,26 +639,15 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", "wasm-bindgen", ] [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.1.5", -] +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "hashbrown" @@ -680,7 +657,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash 0.2.0", + "foldhash", ] [[package]] @@ -694,15 +671,15 @@ dependencies = [ [[package]] name = "hmac-sha1-compact" -version = "1.1.7" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823" +checksum = "7ed9b23855291da44e7e84616e19dc0d969c3580e14ffa6b759f28775e26afc1" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -716,18 +693,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -739,22 +716,43 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" + [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -766,16 +764,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -786,18 +785,20 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -805,6 +806,29 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" + [[package]] name = "ident_case" version = "1.0.1" @@ -840,37 +864,37 @@ checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.17.1", + "hashbrown", ] [[package]] name = "inout" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "hybrid-array", ] [[package]] name = "inventory" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" dependencies = [ "rustversion", ] [[package]] name = "itertools" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ "either", ] @@ -883,19 +907,19 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom", "libc", ] [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -904,9 +928,35 @@ dependencies = [ [[package]] name = "json-escape-simd" -version = "3.0.2" +version = "3.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e770254dd7802184595b1d30da2a15cb72569e2aca2b177aef8d22eac8a693" +checksum = "d302fe9761f19c4123edfe82b66be55a08e54012403cd7e27f8a176538737954" +dependencies = [ + "escape-simd", +] + +[[package]] +name = "lazy-regex" +version = "3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a2cce8a22920a29fdcc8c9170aa5d29edd9fbcf8bea856e24c2cb1c2419f67" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f1e4b1f1d8c71edb4ff2b2150ec4606445fd2ddf67ddc097655daf59286baa6" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 3.0.9", +] [[package]] name = "libbz2-rs-sys" @@ -916,9 +966,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -938,9 +988,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -953,33 +1003,33 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" -version = "0.12.5" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "4a9f71e3591c7ec44d3e563e87bf14ddc2bada8519dca046e5b0e560cd004e2d" dependencies = [ - "hashbrown 0.15.5", + "hashbrown", ] [[package]] name = "lzma-rust2" -version = "0.16.4" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce716bf1a316f47a280fc76295f6495b5bea4752bca01c3b3885e101b1c23c02" +checksum = "f1c46f3fcd2e888afdb01a07efc071cb21300e07c68d32c3e8b01b5c8cb1fcaa" dependencies = [ "sha2", ] [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minimal-lexical" @@ -989,9 +1039,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.9" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" dependencies = [ "adler2", "simd-adler32", @@ -1014,6 +1064,18 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -1032,9 +1094,9 @@ checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" dependencies = [ "num-integer", "num-traits", @@ -1048,9 +1110,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -1092,7 +1154,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1119,17 +1181,11 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - [[package]] name = "oxc" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c837e177cfe9e45dc8b474b16565046b673e5e2e8c2a2aece798ff9a1e104602" +checksum = "d6e790cd34b555e135d0d656139676ae62a50ef9a6829f9aacf9602b7525c501" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1143,6 +1199,7 @@ dependencies = [ "oxc_regular_expression", "oxc_semantic", "oxc_span", + "oxc_str", "oxc_syntax", "oxc_transformer", "oxc_transformer_plugins", @@ -1150,60 +1207,36 @@ dependencies = [ [[package]] name = "oxc-browserslist" -version = "3.0.6" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b87743c2f6963036971ca587539cd9235693d953dc018a734014241afb20d5b" +checksum = "456720daa870e4f69f8d29fded1c8deea82da4780a6b8553baa160eb0cd8d864" dependencies = [ - "flate2", + "js-sys", + "libc", + "miniz_oxide", "postcard", "rustc-hash", "serde", - "thiserror", -] - -[[package]] -name = "oxc-miette" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b776084bf11ad750806cb63f9ed2606a12ab374cf458f36a7731eba1f5d753fc" -dependencies = [ - "cfg-if", - "owo-colors", - "oxc-miette-derive", - "textwrap", - "thiserror", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "oxc-miette-derive" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e0bafc397eee2f94c5bf89bb5a82ba6d522d1b79e2924c8169f053febb433f" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror 2.0.21", ] [[package]] name = "oxc_allocator" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b34ec71e68e73a0ed7d929e58ab2bb4f58752dea944c08f14ccc3b8272c77946" +checksum = "45c8f46c8923b4afb4dd588d4684d5dd32332f1341225c066cc6af31094e7e3d" dependencies = [ "allocator-api2", - "hashbrown 0.17.1", + "hashbrown", "oxc_data_structures", "rustc-hash", ] [[package]] name = "oxc_ast" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa406c62bb247767a6a051be6ac3be7db6c4f818129ccae570da6ee9eb96ead0" +checksum = "35465751cfc99739fee42d8345b57d5b5087b462bea7bc5bb17d1f248e1efa22" dependencies = [ "bitflags", "oxc_allocator", @@ -1219,21 +1252,21 @@ dependencies = [ [[package]] name = "oxc_ast_macros" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3706e193b659865e03155a44e443f0447abf6789f9a3eb436ae10e557afab899" +checksum = "e78e5d50ecf9fbf6c00afb29dbd670784b2ac407fd676f625d1f3515a1a2d577" dependencies = [ "phf", "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] name = "oxc_ast_visit" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5353724c6c835bfd37ed59db65ccd506b7721598bcbe4750eda165754d105463" +checksum = "6836d2fde033c44eae57ab1be3fcc7bd6a57465783bdfce654ca28064a334dbe" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1243,17 +1276,16 @@ dependencies = [ [[package]] name = "oxc_codegen" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d57d63987a27e8e67bc40518a4133b96ad2c0475f4735b8110e577a25680a09e" +checksum = "75db0d260bb5493e31be0ab17651d428da5cd8c49ec2cf78d857e605d66385dd" dependencies = [ "bitflags", "cow-utils", - "dragonbox_ecma", - "itoa", "oxc_allocator", "oxc_ast", "oxc_data_structures", + "oxc_ecmascript", "oxc_index", "oxc_semantic", "oxc_sourcemap", @@ -1261,13 +1293,14 @@ dependencies = [ "oxc_str", "oxc_syntax", "rustc-hash", + "smallvec", ] [[package]] name = "oxc_compat" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a25f516a7f4c05f66da375d07b29a87fe0e6037c79374f409153cb9496f3e0" +checksum = "90809c31d39f49d2023b090ffcfd13b5f2e07b156abde430f1b1454ceef51bb1" dependencies = [ "cow-utils", "oxc-browserslist", @@ -1278,45 +1311,58 @@ dependencies = [ [[package]] name = "oxc_data_structures" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde9ca13ef89018fb4c18502cefb9668baac6066905d27a914c29fb5fae1c8ac" +checksum = "a46b759a8cb13a1111649370f1d769322b082edabd625466254f238818bdc9e9" dependencies = [ "ropey", ] [[package]] name = "oxc_diagnostics" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2cbafa6cc3c38d72e35cd9337a38a4558e9fee4521fa5cabe321b90519e80aa" +checksum = "5b80f4b05a80879518ebe85a8df9368758ebc1e176327b659e1e97389f8561c7" dependencies = [ + "bytecount", "cow-utils", - "oxc-miette", + "itoa", + "memchr", + "oxc_span", "percent-encoding", + "smallvec", + "textwrap", + "unicode-segmentation", + "unicode-width", ] [[package]] name = "oxc_ecmascript" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55408e4f458ec4b9cd842f67364e9395b28bdf2201c7755ed04d6707c1084ea" +checksum = "f8c634665e0be5dde95549146734ab4939b8d1b5d2e7cc006d38b9f5d17a58a2" dependencies = [ "cow-utils", + "dragonbox_ecma", + "itoa", "num-bigint", "num-traits", "oxc_allocator", "oxc_ast", + "oxc_compat", + "oxc_data_structures", "oxc_regular_expression", "oxc_span", + "oxc_str", "oxc_syntax", + "smallvec", ] [[package]] name = "oxc_estree" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6c062d52b9ff15d118b87679bdd0a05c43599c1d55fd1cab280da1cc822858" +checksum = "5dcdd8235762cc9305d619842b73c1e89a0ebec8ccf2f27142ef597521443c1f" [[package]] name = "oxc_index" @@ -1330,9 +1376,9 @@ dependencies = [ [[package]] name = "oxc_isolated_declarations" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589c261039e8530a6d374502265a976f5a4b0760753abffe56590d954c67607b" +checksum = "a6d9cef33b435f8a3cf25ac1b130654edf428aee5297602cc02f23d0aa57ffb1" dependencies = [ "bitflags", "oxc_allocator", @@ -1348,14 +1394,15 @@ dependencies = [ [[package]] name = "oxc_mangler" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf6fc583affb70dcc496a185bb4f6d47bc7d921a06a8c03baaa2c185082bd8e3" +checksum = "357d27012ddc6ec3f519e4190b3c41efef505e0581cc1ae951a72db7728f0ea4" dependencies = [ "itertools", "oxc_allocator", "oxc_ast", "oxc_data_structures", + "oxc_ecmascript", "oxc_index", "oxc_semantic", "oxc_span", @@ -1366,12 +1413,13 @@ dependencies = [ [[package]] name = "oxc_minifier" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35d55fd306d180df685938f822f757007996929755e9223810be206538e86d9" +checksum = "5d2be59e492f818fb677c1d16a6677401ed9b35d7fe6c101646aba4e653a288f" dependencies = [ "cow-utils", "itoa", + "lazy-regex", "oxc_allocator", "oxc_ast", "oxc_ast_visit", @@ -1381,7 +1429,6 @@ dependencies = [ "oxc_index", "oxc_mangler", "oxc_parser", - "oxc_regular_expression", "oxc_semantic", "oxc_span", "oxc_str", @@ -1392,9 +1439,9 @@ dependencies = [ [[package]] name = "oxc_parser" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6efc796fc0c65a2a6bd40984b65b1052b5550b017af303f02b08794e304c14c8" +checksum = "635c4e1bb876a9d4ee0e338fa68547a1d75add58637e5c3921fb81ae10a60439" dependencies = [ "bitflags", "cow-utils", @@ -1416,9 +1463,9 @@ dependencies = [ [[package]] name = "oxc_regular_expression" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e533f1345534dceaee6c2c7102e5f3d2e7466269199eca9abeb871e02a7496a4" +checksum = "3ae3f2c42773d16336a75d5c61dae45a34aea399f0b5399a7a25c8509d79662f" dependencies = [ "bitflags", "oxc_allocator", @@ -1433,9 +1480,9 @@ dependencies = [ [[package]] name = "oxc_semantic" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de31d2015099e33ecdc23ad59730b768a141a0b2941d43970a26ba50de003e10" +checksum = "b24ee2095e2ddac60c2063b59693bbf2b80936c8a9e036463b153a203857831b" dependencies = [ "itertools", "memchr", @@ -1456,9 +1503,9 @@ dependencies = [ [[package]] name = "oxc_sourcemap" -version = "8.0.2" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ac6db7d8c33f46a0b9ebb702c077364abcd6b64c3a3a97bb86b9f5b3554833c" +checksum = "1133371a5fadc14b9f66a8fec14be3564a77647c7ab21b1ef0ec973bd82613ce" dependencies = [ "base64-simd", "json-escape-simd", @@ -1469,12 +1516,11 @@ dependencies = [ [[package]] name = "oxc_span" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5245fa99a7c9405d57e2d9666afc9fd01ac644e37aff1cb3e1cedd50c17915" +checksum = "0cb5bee6f7a9829ddc4d8c05102fe1aa7e2ca31c7ff0bcfbb7069ca977334840" dependencies = [ "compact_str", - "oxc-miette", "oxc_allocator", "oxc_ast_macros", "oxc_estree", @@ -1483,21 +1529,22 @@ dependencies = [ [[package]] name = "oxc_str" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c61444d5681ce805fbbb5dc0498d980685a4c6a82dd57c2e17ff948041c1fb" +checksum = "c04d966af690d3b780ee4c47436174e558750eed6ca40b07d695fa4910ad8e33" dependencies = [ "compact_str", - "hashbrown 0.17.1", + "hashbrown", + "memchr", "oxc_allocator", "oxc_estree", ] [[package]] name = "oxc_syntax" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "296b94ecc1235a2b5ea2a47412d4bc573b7a25a93132a1a2f49c617d85b58995" +checksum = "684d5f6db25b6e3d026582b3926166c80eeda9aac8a879782292c6a33b7d13a0" dependencies = [ "bitflags", "cow-utils", @@ -1515,9 +1562,9 @@ dependencies = [ [[package]] name = "oxc_transformer" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494dcdb3e8c8dcb50e2ce3ba238030c9116a8b47b54f55c0f0211d68ae66b183" +checksum = "fec592c64f6455678cbdb76635144f76825fcaf85f9509d814bd6a9e2bfde160" dependencies = [ "base64", "compact_str", @@ -1545,9 +1592,9 @@ dependencies = [ [[package]] name = "oxc_transformer_plugins" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6ce80c16ee776e23a0bcb472153db9ec66c59b709fcdb63f43f7623cdfadce7" +checksum = "2470e565dc13884f9a1e1c08d8eaad3c927e807d867a118c0f0287534971327f" dependencies = [ "cow-utils", "itoa", @@ -1568,9 +1615,9 @@ dependencies = [ [[package]] name = "oxc_traverse" -version = "0.137.0" +version = "0.155.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da52a3734ef3ad4c10ebb6f4e768f63ea1597677d53785e3ceb26d9735f5c1f8" +checksum = "b2ba98ff0dc34cb2e45bcbe3cee395e792298fad77ca63c574766571f859cc56" dependencies = [ "itoa", "oxc_allocator", @@ -1635,9 +1682,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -1646,9 +1693,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -1656,37 +1703,35 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] [[package]] name = "php_quickjs" -version = "0.0.2" +version = "0.1.0" dependencies = [ + "cc", "ext-php-rs", "lru", "oxc", - "rmp-serde", "rquickjs", - "serde", - "serde_bytes", "serde_json", "sourcemap", ] @@ -1699,9 +1744,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "postcard" @@ -1717,34 +1762,26 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppmd-rust" -version = "1.4.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] +checksum = "cd4acc8d264ea385db64ccfdcdfd5909ee9eda0975666723d59f3bfec80b3611" [[package]] name = "proc-macro-crate" @@ -1757,9 +1794,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -1777,19 +1814,13 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1813,9 +1844,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1825,9 +1856,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1842,30 +1873,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "relative-path" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0" -dependencies = [ - "serde", -] - -[[package]] -name = "rmp" -version = "0.8.15" +version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +checksum = "38540a682c6e9bca04a8bca9e5ec5a6ba168dfbb04c9998fe517a9befb92eb68" dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", + "serde_core", ] [[package]] @@ -1880,9 +1892,9 @@ dependencies = [ [[package]] name = "rquickjs" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0688f8b0192998cca685adefdfad3483da295fa40a0ec406b4c14ecd729e858" +checksum = "a0a22bf72f515cc4189dd7c63d016f6de92a11eee98ddf7dd64794198f6eb34e" dependencies = [ "rquickjs-core", "rquickjs-macro", @@ -1890,52 +1902,50 @@ dependencies = [ [[package]] name = "rquickjs-core" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fee8c5383f0cfda3b980a80ca4520e726e09b593c59562f579daa51b6c20411" +checksum = "6657979a24d543e5fc66f32cef2f47ca82ce8eb55cec5f7d59a12b7b570bbf24" dependencies = [ - "hashbrown 0.17.1", + "hashbrown", "relative-path", "rquickjs-sys", ] [[package]] name = "rquickjs-macro" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e6cf4b6e695b526cb430a6f445d3ccb9908696b99f1c1f8a1480af38bed5e6" +checksum = "0a2ea9adb03b669c933482ff740b683cd36be7cef23891657a8ccb850401d286" dependencies = [ "convert_case", - "fnv", - "ident_case", "indexmap", "proc-macro-crate", "proc-macro2", "quote", "rquickjs-core", - "syn", + "syn 3.0.9", ] [[package]] name = "rquickjs-sys" -version = "0.12.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "698077537c286a169de8693b216672bcef148bf2e2e112ebf50758c68e9afa09" +checksum = "cee271d0eeba64f0915b846cb7ae02e16faf3dfdffdca91731101d9d30fe3423" dependencies = [ "cc", ] [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -1946,24 +1956,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -1976,9 +1980,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" dependencies = [ "windows-sys", ] @@ -2014,9 +2018,9 @@ dependencies = [ [[package]] name = "self_cell" -version = "1.2.2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" [[package]] name = "semver" @@ -2036,49 +2040,39 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", ] -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -2100,21 +2094,15 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures", "digest", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "shlex" version = "2.0.1" @@ -2123,28 +2111,30 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "skeptic" -version = "0.13.7" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" +checksum = "d82adb9b794e23afb98bb1c89fd84b1971dc74d07b476a3967a8c95b13c2f678" dependencies = [ "bytecount", "cargo_metadata", - "error-chain", "glob", "pulldown-cmark", + "serde", + "serde_json", "tempfile", + "thiserror 1.0.69", "walkdir", ] @@ -2156,9 +2146,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -2213,9 +2203,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -2224,13 +2225,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] @@ -2246,7 +2247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom", "once_cell", "rustix", "windows-sys", @@ -2254,40 +2255,60 @@ dependencies = [ [[package]] name = "textwrap" -version = "0.16.2" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" dependencies = [ + "icu_segmenter", "smawk", - "unicode-linebreak", "unicode-width", ] [[package]] name = "thiserror" -version = "2.0.18" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", ] [[package]] name = "time" -version = "0.3.51" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "js-sys", @@ -2305,28 +2326,29 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", "toml_datetime", @@ -2336,18 +2358,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "typed-path" -version = "0.12.3" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" [[package]] name = "typenum" @@ -2357,27 +2379,21 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-id-start" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" +checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-segmentation" @@ -2393,9 +2409,9 @@ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "ureq" -version = "3.3.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b" dependencies = [ "base64", "der", @@ -2411,9 +2427,9 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ "base64", "http", @@ -2447,9 +2463,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.4" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "js-sys", "wasm-bindgen", @@ -2461,12 +2477,6 @@ 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 = "vsimd" version = "0.8.0" @@ -2483,20 +2493,11 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2507,9 +2508,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2517,31 +2518,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] @@ -2572,24 +2573,18 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -2602,9 +2597,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2613,13 +2608,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", "synstructure", ] @@ -2634,39 +2629,41 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -2674,20 +2671,20 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.9", ] [[package]] name = "zip" -version = "8.6.0" +version = "9.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +checksum = "ec2e3161de27091f18a831d9494e9c53378a34833af5c1dda35ad85849da18d6" dependencies = [ "aes", "bzip2", @@ -2695,16 +2692,18 @@ dependencies = [ "crc32fast", "deflate64", "flate2", - "getrandom 0.4.3", + "getrandom", "hmac", "indexmap", "lzma-rust2", "memchr", + "nix", "pbkdf2", "ppmd-rust", "sha1", "time", "typed-path", + "windows-sys", "zeroize", "zopfli", "zstd", @@ -2712,21 +2711,21 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.4" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -2736,27 +2735,27 @@ dependencies = [ [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.2.4" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 18ac07a..7d6b6b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "php_quickjs" -version = "0.0.2" +version = "0.1.0" edition = "2021" -rust-version = "1.96" +rust-version = "1.97" description = "Embed a QuickJS sandbox in PHP with typed, bidirectional communication" license = "MIT" authors = ["Edd Mann "] @@ -17,22 +17,22 @@ name = "php_quickjs" crate-type = ["cdylib"] [dependencies] -ext-php-rs = { version = "0.15", features = ["closure"] } -rquickjs = { version = "0.12", default-features = false, features = [ +ext-php-rs = { version = "0.16.3", features = ["closure"] } +rquickjs = { version = "0.14.0", default-features = false, features = [ "std", "macro", "classes", "array-buffer", ] } -serde = { version = "1", features = ["derive"] } -serde_bytes = "0.11" -rmp-serde = "1" -serde_json = "1" +serde_json = "1.0.154" # TypeScript fast path: transpile guest TS -> JS in-process, remap errors. -oxc = { version = "0.137", features = ["full"] } -sourcemap = "9" -lru = "0.12" +oxc = { version = "0.155.0", features = ["full"] } +sourcemap = "9.3.2" +lru = "0.19.0" + +[build-dependencies] +cc = "1.10.0" [profile.release] opt-level = 3 diff --git a/Dockerfile-dev b/Dockerfile-dev new file mode 100644 index 0000000..04f2f64 --- /dev/null +++ b/Dockerfile-dev @@ -0,0 +1,33 @@ +FROM composer:2 AS composer + +FROM php:8.5-cli-trixie + +ARG RUST_VERSION=1.99.1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + clang \ + curl \ + git \ + libclang-dev \ + make \ + pkg-config \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +ENV CARGO_HOME=/opt/cargo \ + RUSTUP_HOME=/opt/rustup \ + PATH=/opt/cargo/bin:${PATH} + +COPY --from=composer /usr/bin/composer /usr/local/bin/composer + +RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --profile minimal --default-toolchain "${RUST_VERSION}" \ + && rustup component add rustfmt clippy --toolchain "${RUST_VERSION}" + +WORKDIR /workspace + +ENV CARGO_TARGET_DIR=/workspace/target/docker + +CMD ["make", "test"] diff --git a/Makefile b/Makefile index e9ca43b..8fd1f73 100644 --- a/Makefile +++ b/Makefile @@ -10,10 +10,12 @@ else CARGO_FLAGS := endif -EXT := $(CURDIR)/target/$(PROFILE)/libphp_quickjs.so +EXT_SUFFIX := $(if $(filter Darwin,$(shell uname -s)),dylib,so) +TARGET_DIR := $(if $(CARGO_TARGET_DIR),$(CARGO_TARGET_DIR),$(CURDIR)/target) +EXT := $(TARGET_DIR)/$(PROFILE)/libphp_quickjs.$(EXT_SUFFIX) PHP := php -d extension=$(EXT) -.PHONY: all build release test test-rust test-php stubs example clean fmt +.PHONY: all build release test test-rust test-php test-docker stubs example clean fmt all: build @@ -26,6 +28,9 @@ release: # Rust unit tests (marshaling, manifest, facade) + the PHP integration suite. test: build test-rust test-php +test-docker: + docker compose run --rm --build dev + test-rust: cargo test --lib @@ -40,8 +45,9 @@ test-php: build # Regenerate the IDE stub for the PHP-facing classes (requires cargo-php: # cargo install cargo-php). stubs: - cargo php stubs --stdout > stubs/php_quickjs.stubs.php || \ - echo "cargo-php not installed; run 'cargo install cargo-php'" + @tmp=$$(mktemp stubs/php_quickjs.stubs.php.XXXXXX); \ + trap 'rm -f "$$tmp"' EXIT HUP INT TERM; \ + cargo php stubs --stdout > "$$tmp" && mv "$$tmp" stubs/php_quickjs.stubs.php example: build @for ex in examples/*.php; do \ diff --git a/README.md b/README.md index 076d37b..c5ee934 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,19 @@ directly in your PHP process. Guest code runs in an isolated context with memory time, and stack limits; PHP exposes a controlled allowlist of capabilities into JS; and values, functions, and errors cross the boundary both ways. Guest code may be TypeScript — it's transpiled in-process and runtime errors map back to the original -TS source. +TS source. Pass `typescript: false` to `eval()` for JavaScript that should run +directly without transpilation. + +Since 0.1.0, each runtime and its TypeScript compiler run on a dedicated native +thread. PHP callbacks, objects and closures remain on the PHP thread. With +Revolt autoloaded, waiting suspends the calling Fiber rather than the PHP thread. +Synchronous `php.*` callbacks and `Js\Callback` keep their existing API, including +nested calls. +JavaScript always executes on its dedicated worker, including callbacks invoked +from PHP. A synchronous PHP host call pauses its calling JS frame; the worker +can continue processing ready jobs and nested callback requests while PHP runs. +Call `$js->close()` to interrupt JS, join its worker and release PHP capabilities. +Close live runtimes before `fork()` and create new runtimes in the child; inherited runtimes reject entries in the child. Built in Rust with [`ext-php-rs`](https://github.com/davidcole1340/ext-php-rs) and [`rquickjs`](https://github.com/DelSkayn/rquickjs). QuickJS-NG is bundled — no system @@ -75,42 +87,128 @@ typed capabilities, `dts()`, isolated realms — in [`examples/`](examples): ## Installation -Prebuilt binaries are attached to each -[release](https://github.com/eddmann/php-quickjs/releases) for PHP 8.4 / 8.5 — -self-hosted Linux, AWS Lambda (a ready Bref layer), and macOS (Apple Silicon). Enable -the one matching your platform: +Upstream [releases](https://github.com/eddmann/php-quickjs/releases) provide binaries +for PHP 8.4 / 8.5 on Linux, AWS Lambda and macOS (Apple Silicon). Those binaries may +not include the worker described here; build this fork at a worker revision when it is +required. Enable the binary matching your platform: ```ini ; php.ini extension=/path/to/php-quickjs-...so ``` -Or build from source (Rust 1.96+, clang, PHP dev headers — a plain cargo `cdylib`, no +Or build from source (Rust 1.97+, clang, PHP dev headers — a plain cargo `cdylib`, no `phpize`): ```sh -git clone https://github.com/eddmann/php-quickjs && cd php-quickjs -make build +git clone https://github.com/xtrime-ru/php-quickjs && cd php-quickjs +# Check out a revision containing the worker before building. +make release ``` +To build and test with PHP 8.5 + Rust 1.99.1 in Docker: + +```sh +docker compose run --rm --build dev +``` + +The repository is mounted at `/workspace`; Cargo output stays in +`target/docker`, and dependency caches stay under `.cache/`. + → Full platform matrix, Docker, and AWS Lambda / Bref instructions: **[docs/install.md](docs/install.md)**. ## How it works -``` -PHP (trusted) ──ext-php-rs──► Rust bridge ──rquickjs──► QuickJS (untrusted) - register() dispatch table php.module.fn() - eval() __host(name, bytes) frozen php.* facade +```mermaid +%%{init: {"theme":"base","themeCSS":".cluster-label text { fill:#c4c7c5 !important; } .edgeLabel .background { fill:#121212 !important; opacity:1 !important; }","htmlLabels":false,"markdownAutoWrap":false,"themeVariables":{"darkMode":true,"primaryTextColor":"#e6e1e5","textColor":"#e6e1e5","fontFamily":"system-ui, sans-serif","fontSize":"15px","lineColor":"#9aa0ab","edgeLabelBackground":"#121212","clusterBkg":"#1a1c20","clusterBorder":"#35383e"},"flowchart":{"htmlLabels":false,"curve":"linear","rankSpacing":45,"nodeSpacing":32}}}%% +flowchart TB + subgraph Canvas[" "] + direction TB + subgraph PHP["PHP THREAD"] + direction TB + API("PHP API") + Loop("Revolt event loop") + Fiber("Waiting Fiber") + end + Cmd["Command queue"] + subgraph Worker["QUICKJS WORKER THREAD"] + direction TB + Driver("Worker loop") + Compiler("Oxc compiler") + JS("QuickJS runtime") + Jobs("Promise jobs") + Driver -->|"TypeScript"| Compiler + Compiler -->|"JavaScript"| JS + Driver -->|"execute"| JS + JS -->|"microtasks"| Jobs + end + Events["Event queue"] + Messages["Message queue"] + API -->|"eval · JS callback"| Cmd + Cmd -->|"Request"| Driver + JS -->|"Done · Messages"| Events + Jobs -->|"Promise settled"| Events + JS -->|"postMessage"| Messages + Events -->|"native notification"| Loop + Loop -->|"result / error"| Fiber + Messages -->|"await / drain"| Fiber + end + style Canvas fill:#121212,stroke:#121212,stroke-width:80px,rx:12,ry:12 + style PHP fill:#171e2b,stroke:#486185,rx:8,ry:8 + style Worker fill:#1a1c20,stroke:#35383e,rx:8,ry:8 + classDef host fill:#1e293b,stroke:#486185,color:#dbeafe,stroke-width:1.5px + classDef worker fill:#1c302c,stroke:#41685a,color:#d1fae5,stroke-width:1.5px + classDef queue fill:#302b23,stroke:#786746,color:#fef3c7,stroke-width:1.5px + class API,Loop,Fiber host + class Driver,Compiler,JS,Jobs worker + class Cmd,Events,Messages queue + ``` -Everything the guest reaches goes through a single `__host` import and a flat dispatch -table; the namespaced `php.*` tree is frozen JS built from your registrations. Values -cross as MessagePack, functions as references backed by registries, and errors bridge -both ways — remapping to TS coordinates on the way out. +1. PHP sends an evaluation, JS callback or PHP callback reply through the **command + queue**. Only owned, typed values cross threads; PHP objects and closures remain + on the PHP thread. +2. The worker executes JS and ready Promise jobs. Pending Promises stay associated + with their request IDs. A synchronous PHP call pauses its JS frame while the + worker can process ready jobs and nested JS callbacks. +3. Results, errors and PHP call requests travel through the **event queue**. A native + readiness notification wakes Revolt, which resumes the appropriate waiting Fiber. + Without Revolt, PHP waits synchronously. Synchronization uses no periodic polling. +4. `quickjs.postMessage()` writes snapshots to a separate, bounded **message queue**; + PHP consumes them with `drainMessages()` or `awaitMessages()`. + +Registered PHP capabilities go through one direct native entry point and a flat +dispatch table; the namespaced `php.*` tree is frozen JS built from your +registrations. The separate `quickjs.postMessage()` sink copies data into a +bounded native queue. Host calls, eval, saved callbacks and messages use native +conversion. Functions cross as registry references, and errors bridge both +ways with TS source locations. → **[docs/architecture.md](docs/architecture.md)** for the full design. +## Asynchronous execution + +`eval()` and `Js\Callback` automatically await returned Promises. With Revolt +autoloaded, waits suspend the calling Fiber and worker notifications resume it; +rejections become PHP exceptions. Manual job APIs remain available for detached work. See +[asynchronous execution](docs/async.md). + +Async consumers can send data-only notifications with `quickjs.postMessage(value)` +and read them using `QuickJS::drainMessages()`. The native queue snapshots values +at send time and has a configurable aggregate byte limit. `timeoutMs` remains an +optional wall-clock limit for a complete call, including Promise waits. + +Resource budgets are separate: `memoryLimit` bounds the QuickJS heap, while +`maxQueuedMessageBytes` bounds retained native messages (32 MiB by default, +including accounting overhead). Native value conversion permits nesting up to +64 levels; it has no separate 16 MiB value limit. The TypeScript LRU cache defaults to +at most 256 entries and 32 MiB of source, generated JavaScript and source-map +strings. Set constructor arguments `transpileCacheMaxBytes` and +`transpileCacheMaxEntries` to customize these limits; both are non-negative, and +`0` in either disables caching. This cache budget does not bound temporary Oxc allocations. Direct +JavaScript evaluation with `typescript: false` bypasses Oxc and this cache. + ## Scope This is an *embedder*, not a standalone defence against hostile code. The capability @@ -130,6 +228,13 @@ attacker-controlled code, nest the extension inside an outer microVM / gVisor bo - [Errors](docs/errors.md) — typed exceptions, both-way bridging, and TypeScript remapping. +## Development + +`make stubs` regenerates the canonical IDE declarations atomically; failed +generation leaves the previous file intact. The PHP test suite compares these +signatures against the loaded extension. Consumers such as puphpeteer copy this +file for static analysis and should keep their copy synchronized. + ## License MIT diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..53dd6b9 --- /dev/null +++ b/build.rs @@ -0,0 +1,21 @@ +use std::{env, process::Command}; + +fn main() { + println!("cargo:rerun-if-changed=src/notification.c"); + println!("cargo:rerun-if-env-changed=PHP_CONFIG"); + let php_config = env::var("PHP_CONFIG").unwrap_or_else(|_| "php-config".into()); + let output = Command::new(php_config) + .arg("--includes") + .output() + .expect("php-config --includes"); + assert!(output.status.success(), "php-config --includes failed"); + let mut build = cc::Build::new(); + build.file("src/notification.c"); + for flag in String::from_utf8(output.stdout) + .expect("PHP include paths") + .split_whitespace() + { + build.flag(flag); + } + build.compile("quickjs_notification"); +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f87dcfa --- /dev/null +++ b/composer.json @@ -0,0 +1,9 @@ +{ + "name": "xtrime/php-quickjs-dev", + "description": "Development dependencies for php-quickjs integration tests", + "license": "MIT", + "require-dev": { + "amphp/amp": "^3.1", + "revolt/event-loop": "^1.0" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..a122a21 --- /dev/null +++ b/composer.lock @@ -0,0 +1,172 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "fe33434037e80a5da91beec643f5f566", + "packages": [], + "packages-dev": [ + { + "name": "amphp/amp", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/amp.git", + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/amp/zipball/73c38b323ff8d790abf0f76c56fcc892bda4111a", + "reference": "73c38b323ff8d790abf0f76c56fcc892bda4111a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" + ], + "psr-4": { + "Amp\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + } + ], + "description": "A non-blocking concurrency framework for PHP applications.", + "homepage": "https://amphp.org/amp", + "keywords": [ + "async", + "asynchronous", + "awaitable", + "concurrency", + "event", + "event-loop", + "future", + "non-blocking", + "promise" + ], + "support": { + "issues": "https://github.com/amphp/amp/issues", + "source": "https://github.com/amphp/amp/tree/v3.1.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-07-19T17:59:20+00:00" + }, + { + "name": "revolt/event-loop", + "version": "v1.0.9", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "44061cf513e53c6200372fc935ac42271566295d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d", + "reference": "44061cf513e53c6200372fc935ac42271566295d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9" + }, + "time": "2026-05-16T17:55:38+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3bfc223 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + dev: + build: + context: . + dockerfile: Dockerfile-dev + image: php-quickjs-dev:php85-rust199 + working_dir: /workspace + volumes: + - .:/workspace + environment: + CARGO_HOME: /workspace/.cache/cargo + COMPOSER_CACHE_DIR: /workspace/.cache/composer + entrypoint: + - bash + - -c + - 'composer install --no-interaction --prefer-dist && exec "$$@"' + - -- + command: [make, test] diff --git a/docs/README.md b/docs/README.md index e483de0..b75bbd9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,7 +7,7 @@ user-facing API and quick start, see the [project README](../README.md). Lambda (Bref), and macOS, plus building from source. - **[API reference](api.md)** — the `QuickJS` class and every method. - **[Architecture](architecture.md)** — the three worlds (PHP / Rust / QuickJS), - the single `__host` bridge, how a call flows end to end, value marshaling, and + the direct host bridge, how a call flows end to end, value marshaling, and bidirectional function passing. - **[Execution modes](execution-modes.md)** — what a *realm* is, shared vs. isolated mode, the realm lifecycle, and how the JS-callback registry is kept @@ -34,15 +34,14 @@ php -d extension=$(pwd)/target/debug/libphp_quickjs.so examples/kitchen_sink.php | `src/engine.rs` | Owns the QuickJS `Runtime`; realm lifecycle (shared vs isolated); deadline + re-entrancy state; the current-context stack. | | `src/sandbox.rs` | In-engine resource containment: the memory limit, native stack size, and wall-clock deadline (interrupt handler). | | `src/transpile.rs` | TypeScript → JavaScript via oxc, plus the content-hash transpile cache. | -| `src/bridge.rs` | The `__host` / `__php_invoke` imports, the dispatch table, the frozen `php.*` facade, and registries. | -| `src/marshal.rs` | `JS value ↔ MiddleValue ↔ PHP zval`, with native-msgpack (de)serialization. | +| `src/bridge.rs` | The native host imports, dispatch table, frozen `php.*` facade, and registries. | +| `src/marshal.rs` | Native `JS value ↔ MiddleValue ↔ PHP zval` conversion. | | `src/callback.rs` | `Js\Callback` — a JS function wrapped as an invocable PHP object. | | `src/handles.rs` | The capability handle table (`int → live zval`). | | `src/error.rs` | Error bridging both ways; JS-stack remapping to TS coordinates. | | `src/exceptions.rs` | The typed `QuickJS*Exception` classes and rich exception construction. | | `src/manifest.rs` | The registration manifest and `.d.ts` generation. | -| `src/js/msgpack.js` | The in-sandbox MessagePack codec (byte-compatible with `MiddleValue`). | -| `src/js/runtime.js` | The in-sandbox runtime: function-ref wrap/unwrap and the JS callback registry. | +| `src/js/runtime.js` | Host dispatch and the JS callback registry. | ## Stack @@ -54,8 +53,7 @@ php -d extension=$(pwd)/target/debug/libphp_quickjs.so examples/kitchen_sink.php refcounting bug class. - **[`oxc`](https://github.com/oxc-project/oxc)** — the TypeScript transform and source maps, in-process. -- **`rmp-serde`** (msgpack) and **`sourcemap`** for the wire format and error - remapping. +- **`sourcemap`** — error remapping. ## Threading diff --git a/docs/api.md b/docs/api.md index d87532a..5b24881 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,22 +4,34 @@ The extension exposes a single `QuickJS` class. For the bigger picture see [architecture](architecture.md); for realms and the callback lifecycle see [execution modes](execution-modes.md). -### `new QuickJS(?int $memoryLimit = null, ?int $timeoutMs = null, ?int $maxStack = null, bool $isolated = false)` +### `new QuickJS(?int $memoryLimit = null, ?int $timeoutMs = null, ?int $maxStack = null, bool $isolated = false, ?int $maxQueuedMessageBytes = null, int $transpileCacheMaxBytes = 33554432, int $transpileCacheMaxEntries = 256)` -Limits default to unbounded; pass non-zero values to contain resource abuse. +`memoryLimit` and `timeoutMs` default to unbounded; pass non-zero values to +contain resource abuse. `maxStack` defaults to the engine stack limit. `isolated: true` runs each `eval()` in a fresh realm (see [execution modes](execution-modes.md)). +`maxQueuedMessageBytes` defaults to 32 MiB and must be positive. `timeoutMs` +bounds the complete call, including Promise waits; `null` disables it. ### `register(string $name, callable $fn, ?string $types = null): void` Expose a PHP callable to JS under a flat, dotted name — it becomes `php.(...)` in the guest. `$types` is an optional TypeScript signature -surfaced by `dts()`. This flat registry is the **entire** trust boundary. +surfaced by `dts()`. This flat registry is the PHP callback allowlist. -### `eval(string $code): mixed` +### `eval(string $code, bool $typescript = true): mixed` -Run TypeScript or JavaScript and marshal the result back to PHP. Errors raise a -`QuickJSEvalException` located at the original TS line/column (see [errors](errors.md)). +With `typescript: true`, transpile TypeScript or JavaScript with Oxc and remap +errors to the input source. With `typescript: false`, execute JavaScript directly; +errors retain the original JavaScript coordinates. Both paths await a returned +Promise and marshal the result to PHP. Errors raise `QuickJSEvalException` +(see [errors](errors.md)). + +The TypeScript cache defaults to at most 256 entries and 32 MiB of source, generated +JavaScript and source-map strings. An entry larger than the budget is evaluated +without caching. Set `transpileCacheMaxBytes` and `transpileCacheMaxEntries` +in the constructor to change these limits. Both must be non-negative; setting +either to `0` disables caching. These are cache limits, not a bound on all Oxc allocations. ### `grant(mixed $resource): int` / `resolve(int $h): mixed` / `revoke(int $h): bool` @@ -35,11 +47,58 @@ $js->register('db.query', fn(int $handle, string $sql) => $js->resolve($handle)- ### `manifest(): array` / `dts(): string` -The registration manifest and a generated TypeScript `.d.ts` for the `php` global, -both from the same source of truth. +The registration manifest and generated TypeScript `.d.ts` for the `php` and +`quickjs` globals. The `php` declaration comes from the registration manifest. ### `roundtrip(mixed $value): mixed` Diagnostic helper: send a PHP value through the full marshaling pipeline (PHP → MiddleValue → JS → MiddleValue → PHP) and return the result. Useful for testing value fidelity across the boundary; not needed in normal use. + + +### `hasPendingJobs(): bool` + +Whether a Promise continuation is ready to run. An unresolved Promise waiting +for host I/O is not a ready job. Available in shared mode only. + +### `executePendingJobs(int $maxJobs = 100): int` + +Execute up to `maxJobs` ready Promise jobs and return the number executed. The +budget must be positive. Returns immediately when the queue is empty; never +waits for external I/O. Jobs queued by a running job count towards the same +budget. The constructor's `timeoutMs` also applies to this call, including an +individual job that does not return; a timeout raises `QuickJSTimeoutException`. + +Only shared mode supports job pumping. Calling `executePendingJobs()` from an active JS +call is rejected. `eval()` and `Js\Callback` automatically await a Promise they +return, including the jobs needed to settle it. They do not drain unrelated, +detached jobs when their own result is not a Promise. +Promise rejections retain JavaScript semantics: use `.catch()`/rejection handlers; +`executePendingJobs()` is not an unhandled-rejection reporting API. + +See [asynchronous execution](async.md) for host event loop integration and PHP +Fiber boundaries. + +### `drainMessages(): array` + +Return and clear messages sent by JS through `quickjs.postMessage(value)`. +Values are copied at send time and contain data only. This method does not +enter JS or execute jobs. See [asynchronous execution](async.md) for limits. + +### `awaitMessages(): array` + +Enable automatic jobs in a shared runtime and await a nonempty message batch. +Only one consumer may wait at a time. Closing the runtime wakes the consumer +with an exception. `Js\Callback::post(...$args): void` provides bounded, +asynchronous dispatch for message-based bridges; failures stop the runtime and +are delivered to the consumer. See [asynchronous execution](async.md). + +## Separate resource limits + +`memoryLimit` applies to the QuickJS heap, not PHP allocations or the transpiler. +Native conversion limits values to 64 nesting levels and does not impose a +16 MiB per-value byte limit. The message queue has its own aggregate accounted +byte limit through `maxQueuedMessageBytes`, including per-message and container +overhead; draining messages releases this budget. Neither that queue budget nor +the 32 MiB TypeScript cache budget replaces the heap limit. diff --git a/docs/architecture.md b/docs/architecture.md index 2a0a24b..fa73201 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,24 +5,25 @@ ``` ┌─ PHP (trusted, full Zend) ──┐ ┌─ Rust extension ─┐ ┌─ QuickJS (untrusted) ─┐ │ $js->register(...) │ │ owns the engine │ │ php.module.fn() │ -│ $js->eval(tsCode) │◄─►│ ONE __host import│◄─►│ frozen php.* facade │ -│ $js->grant($obj) │ │ msgpack marshal │ │ guest TS-as-JS │ +│ $js->eval(tsCode) │◄─►│ host capabilities│◄─►│ frozen php.* facade │ +│ $js->grant($obj) │ │ native marshal │ │ guest TS-as-JS │ └─────────────────────────────┘ └───────────────────┘ └────────────────────────┘ ext-php-rs (zval ↔ Rust) rquickjs (Rust ↔ JSValue) ``` -It is **one process, one thread**. The Rust extension is a `cdylib` that PHP +It is **one process with a dedicated native thread per QuickJS instance**. The Rust extension is a `cdylib` that PHP loads natively; `QuickJS`, `Js\Callback`, and the `QuickJS*Exception` classes are real PHP classes implemented in Rust. -The design has a single key principle: **the namespacing is cosmetic; the trust -boundary is a flat dispatch table reached through one host import.** +The capability trust boundary is a flat dispatch table reached through +`__host`. `quickjs.postMessage()` is a separate data-only output sink with a +bounded native queue. ## How one call flows, end to end Take `php.math.add(2, 3)` from a guest script. -1. **`eval(tsCode)`** (`lib.rs` → `transpile.rs`) runs oxc: types are stripped, +1. **`eval(tsCode)`** (`lib.rs` → `engine.rs` → worker RPC → `transpile.rs`) runs oxc on the worker: types are stripped, the target is esnext (a near-identity transform), and the output is JS plus a source map. The map is cached **host-side**, keyed by a content hash; it never enters the sandbox. QuickJS only ever sees JavaScript. @@ -33,48 +34,40 @@ Take `php.math.add(2, 3)` from a guest script. ```js php.math.add = function () { - return globalThis.__rt.callHost("math.add", Array.prototype.slice.call(arguments)); + return globalThis.__host("math.add", Array.prototype.slice.call(arguments)); }; ``` -3. `__rt.callHost` (`src/js/runtime.js`) **msgpack-encodes** the argument array - and calls `__host("math.add", bytes)`. +3. `__host` in `worker.rs` converts JS arguments to owned `MiddleValue` + data and sends a host-call request. `engine.rs` receives it on the PHP thread. + `bridge.rs` looks the name up in the allowlist, converts arguments to Zend + values and invokes the PHP callable. Only this PHP thread touches Zend. -4. `__host` is the **single** native function Rust injects into the realm — the - entire JS→host entry point. In `bridge.rs` it: - - decodes the msgpack payload to a `MiddleValue` list, - - looks `"math.add"` up in the **dispatch table** (rejects if not registered — - this is the trust boundary), - - converts each arg `MiddleValue → zval`, - - calls the PHP callable via `ZendCallable::try_call`. +4. The reply travels back through the command channel as owned Rust data. The + worker reconstructs its JS result. No JSON encoding is involved. While waiting + for this reply it can service nested JS function requests from PHP. -5. The result travels back `zval → MiddleValue → msgpack bytes`, and `__rt` - decodes it in the realm. `5` lands in the guest. - -Adding a capability never changes this ABI — there is exactly one import and one -dispatch table. The flat, dotted-name list (`manifest()`) is the complete audit -surface. +Adding a capability never changes the dispatch mechanism: all capabilities use +one dispatch table. The flat, dotted-name list (`manifest()`) is the complete +audit surface for PHP callables. ### The facade is generated, and frozen `bridge.rs::build_facade` walks the manifest's dotted names into a nested object -tree, makes each leaf a function calling `__rt.callHost("dotted.name", args)`, then +tree, makes each leaf a function calling `__host("dotted.name", args)`, then **deep-freezes** the whole tree. Freezing is a security requirement, not a nicety: a guest must not be able to reassign `php.http.get` to fool other code. -The facade is (re)built at the start of every `eval` so newly registered -capabilities appear. +The facade is rebuilt when the registration manifest changes. Its frozen object +is reused and rebound at entry otherwise, avoiding repeated compilation. ## Value marshaling -Each side implements exactly one conversion against a neutral middle type, -`marshal.rs::MiddleValue`, which (de)serializes to **native** msgpack (not -serde's tagged-enum form), so the in-sandbox JS codec interoperates byte-for-byte. +Each side converts against a neutral middle type, `marshal.rs::MiddleValue`. +All values cross through native conversion. ``` JS value ──js_to_middle──► MiddleValue ──middle_to_zval──► PHP zval JS value ◄─middle_to_js─── MiddleValue ◄─zval_to_middle─── PHP zval - │ - msgpack bytes (the __host wire form) ``` | JS | MiddleValue | PHP | @@ -93,40 +86,52 @@ Notes: - A PHP array with sequential `0..n` keys becomes a JS `Array`; otherwise a JS object. A non-UTF-8 PHP string crosses as bytes (a `Uint8Array`). - Integers beyond 2^53 lose precision when represented as JS numbers. -- Why msgpack at all, in one process? It gives a clean, binary-safe, documented - ABI for the one `__host` import, and a single canonical serialization that both - the Rust and JS sides share. ## Bidirectional functions -Functions can't be msgpack-encoded, so they cross as **tagged references** and -the real callable is held in a registry on the owning side. +Functions cross as **registry references**: the real callable remains on its +owning side. -- **`{"$__phpfn": id}`** — a PHP callable handed to JS. The callable is stored in - a host-side registry (`bridge.rs`); JS receives a wrapper function that routes +- **`PhpFn(id)`** — a PHP callable handed to JS. The callable is stored in a + host-side registry (`bridge.rs`); JS receives a wrapper function that routes back through `__php_invoke(id, …)`. -- **`{"$__jsfn": id}`** — a JS function handed to PHP. The function is stored in a +- **`JsFn(id)`** — a JS function handed to PHP. The function is stored in a **JS-side** registry (`jsFns` in `runtime.js`); PHP receives a `Js\Callback` object holding the integer `id`. -`runtime.js` does the wrapping: `wrap()` replaces functions with refs before -encoding (outgoing), `unwrap()` replaces refs with callables after decoding -(incoming). The host (Rust) only ever sees the tagged refs. +Rust converts these references without JS-side wrapping or decoding. ### Invoking a JS function from PHP -`Js\Callback::__invoke` (`callback.rs`) re-enters the realm and calls -`globalThis.__invokeJs(id, argsBytes)`, which looks up `jsFns[id]` and runs it. - -The subtlety is **re-entrancy**. A JS callback is often invoked *synchronously -while a host call is already running* (e.g. `php.mapEach(xs, fn)` — PHP calls -`fn` immediately). At that point the runtime is already locked inside a -`Context::with`; calling `with` again would deadlock. So while any host call (or -eval) is active, the live `Ctx` pointer is published on a thread-local -**current-context stack** (`engine.rs`), and `Js\Callback` reuses it instead of -re-locking. Only when invoked *between* evals (no realm active) does it acquire -the lock fresh on the persistent realm. A re-entrancy **depth cap** (200) bounds -runaway PHP→JS→PHP→… recursion. +`Js\Callback::__invoke` (`callback.rs`) looks up the function directly in the JS registry, +converts its arguments, invokes it, and awaits any returned Promise. + +The PHP wrapper holds an integer function ID and an owning engine reference; +actual JS objects never cross threads. PHP callable and resource handles likewise +stay on the PHP side. Owned `MiddleValue` data is `Send`; no unsafe `Send` shim is +used to move a runtime, Zend value, context or function between threads. + +`worker.rs` owns the runtime and processes requests, host replies and ready jobs. +Returned Promises are tracked by request ID; their intrinsic completion handlers +append only ready IDs to a local queue. There is no second async executor or +periodic polling. The worker blocks on the command channel until a command or +the nearest execution deadline. Synchronous host waits use the same dispatcher +and service nested function requests, preventing PHP -> JS -> PHP deadlocks. A depth cap of +200 and inherited deadlines bound recursion. Isolated runtimes cancel remaining +callback requests before releasing their context. + +`engine.rs` shares one wait loop for RPC results and native message batches. A +coalesced descriptor notification dispatches results to their waiting Fibers; +host callbacks run on the calling PHP thread and may suspend. Manifest updates +are sent only when registrations change, and a cached facade is reused in JS. +Without an autoloaded Revolt driver, synchronous requests use a blocking receive +loop and unresolved external waits fail clearly. See [asynchronous execution](async.md). + +The cached facade and pending Promise roots are explicitly released before +context teardown, including panic unwinding, so native closure cycles cannot +retain a dead runtime. `close()` interrupts and joins the worker, wakes every +waiter, then releases PHP capabilities and handles. User cleanup cannot prevent +those wakeups. The engine validates its creator PID on entry. ## Capability handles diff --git a/docs/async.md b/docs/async.md new file mode 100644 index 0000000..df1e814 --- /dev/null +++ b/docs/async.md @@ -0,0 +1,83 @@ +# Worker execution, Promise jobs and PHP Fibers + +Since 0.1.0, each `QuickJS` owns a native OS thread. The runtime, contexts, +JavaScript values, Promise jobs, TypeScript compiler and transpile cache stay on +that thread. +The PHP API remains synchronous in shape: `eval()` and `Js\Callback` await their +result, including returned Promises and thenables. Detached jobs remain queued +until `executePendingJobs()` or a returned Promise requires them. + +## PHP scheduling + +Autoload `revolt/event-loop` to suspend the calling PHP Fiber during JS execution. +A native descriptor wakes only the PHP callers whose results are ready. Linux +uses eventfd; macOS uses a socket pair. PHP streams wrap the descriptor directly, +so CGI and FPM are supported too. The watcher is unreferenced while idle. Without +Revolt, simple synchronous calls use a blocking receive loop on the PHP thread; +external Promise waits fail with an explicit autoload error. Guest code still +runs on its worker in both cases. + +JS calls such as `php.math.add(2, 3)` preserve their synchronous semantics. The +worker sends copied arguments and waits for PHP to dispatch the allowlisted +capability on the original PHP thread. A PHP callback can await Amp I/O. Its +Fiber yields, and other PHP Fibers and independent JS workers can progress. +CPU-bound PHP callbacks still block PHP and should be offloaded separately. + +Nested PHP -> `Js\Callback` calls are serviced by the same worker while it awaits +its host reply. Promise results are tracked independently, so one pending +callback does not prevent another callback from resolving it. Nested calls share +the active wall-clock deadline and have a depth limit of 200. Reentrant `eval()` +and manual job pumping are rejected. A callback may complete on its worker while +PHP is blocked; this differs from the old PHP-thread execution order. + +## Deadlines and shutdown + +`timeoutMs` bounds an eval, callback or job batch, including host waits. `null` +disables it. QuickJS's interrupt hook checks the deadline on the worker. A +blocking PHP callback cannot itself be interrupted; its late reply is discarded +if the worker has already timed out. The runtime can recover after a timeout. + +Call `$js->close()` to interrupt executing JS, reject pending calls, join the +worker and release registered PHP capabilities, handles and queued messages. +Close is idempotent; new entries and registrations fail afterwards. The last +owner also joins the worker when destroyed. A saved `Js\Callback` retains its +owning runtime until released or explicitly closed. PHP Future cancellation +alone does not call `close()`; consumers decide whether to abort the whole runtime. + +Close live runtimes before `fork()`, then create new runtimes in the child. An inherited runtime cannot be used in a child. +Linux and macOS are supported. + +## Native messages + +`quickjs.postMessage(data)` snapshots null, booleans, numbers, strings, +`Uint8Array`, arrays and records. Functions, symbols, cycles and nesting beyond +64 levels are rejected. The `quickjs` global and method are frozen. PHP retrieves +and clears messages with `$js->drainMessages()` without entering JS. + +`$js->awaitMessages()` opts a shared runtime into automatic job processing and +waits for a nonempty batch, yielding through Revolt. Use one message consumer per +runtime. `Js\Callback::post(...$args)` queues a call without awaiting its result; +admission is bounded to 128 outstanding calls and yields when full. A posted +call failure stops the worker and reaches the message consumer. Synchronous +`__invoke()` and `call()` keep their existing result and exception semantics. + +Guest bridges can use `quickjs.now()` for monotonic milliseconds and +`quickjs.reportError(error)` to stop the worker on a background failure. + +The shared native queue has an aggregate accounted-byte limit, 32 MiB by default, +configured through `maxQueuedMessageBytes`. Accounting includes containers and +per-message overhead. Conversion runs outside the queue mutex: JS getters may +call PHP, which may drain the queue. A rejected message leaves older messages +intact. Generic values returned from eval/callbacks have no separate byte cap. +Transport commands are bounded to 256 outstanding PHP operations; nested bridge +calls also obey the depth limit. These are admission and message budgets, not a +process RSS limit. Copies and the JS heap consume additional memory. + +## Detached jobs + +`hasPendingJobs()` reports ready Promise jobs, excluding unresolved I/O. +`executePendingJobs($maxJobs = 100)` executes at most that many ready jobs and +returns their count without waiting for external I/O. Both require shared mode. +The worker awaits only the returned Promise, leaving later detached jobs queued. +Isolated mode discards its context and runtime, including detached jobs and +unfinished nested callbacks, at the end of each eval. diff --git a/docs/errors.md b/docs/errors.md index f027be4..33faedb 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -1,8 +1,10 @@ # Errors Errors cross the boundary in both directions, and a JS/TS error that escapes -`eval()` surfaces as a real, typed PHP exception located at its **original -TypeScript** coordinates. +`eval()` surfaces as a real, typed PHP exception. With the default +`typescript: true`, source maps recover the original TypeScript coordinates. +With `typescript: false`, JavaScript executes directly and errors retain their +original `guest.js` coordinates. ## Exception hierarchy @@ -52,23 +54,34 @@ What each accessor gives you: - **`getMessage()`** — the error text only (`"TypeError: boom"`), with a bare `Error` name elided to avoid a redundant prefix. -- **`getFile()` / `getLine()`** — the original **TS** location, so the standard - PHP idioms (`getLine()`, `getTraceAsString()`, `(string) $e`) read naturally. +- **`getFile()` / `getLine()`** — the original source location: `guest.ts` + with transpilation, or `guest.js` for direct JavaScript. - **`getJsName()`** — the JS error constructor (`TypeError`, `RangeError`, a custom subclass name, …), or the originating PHP class for a re-surfaced host error. -- **`getJsStack()`** — the stack **remapped to TS coordinates** and **filtered to - guest frames**: the internal bridge/bootstrap frames are removed, so it reads - like a plain TS trace. +- **`getJsStack()`** — with transpilation, the stack is remapped to TS + coordinates and filtered to guest frames. Direct JavaScript retains its + original stack, including any bridge frames. + +For direct JavaScript, no source map is needed: + +```php +try { + $js->eval("\n\nthrow new TypeError('boom');", typescript: false); +} catch (QuickJSEvalException $e) { + $e->getFile(); // "guest.js" + $e->getLine(); // 3 +} +``` ### How the remapping works -The module is named `guest.ts` when handed to QuickJS, so stack frames reference +With `typescript: true`, the module is named `guest.ts` when handed to QuickJS, so stack frames reference it. On a throw, `error.rs` reads the JS stack (generated-JS coordinates), and for each frame referencing the guest module it looks the position up in the module's **source map** (kept host-side from transpilation) and rewrites it to the original TS `line:col`. Frames that don't reference the guest module — the -`__rt`/facade plumbing — are dropped. +facade plumbing — are dropped. ### Non-`Error` throws are surfaced, not lost @@ -84,15 +97,17 @@ collapsing to a generic "uncaught" string. ### Syntax / transpile errors are located A guest that doesn't parse surfaces as a `QuickJSEvalException` with -`getJsName() === 'SyntaxError'` and `getLine()` pointing at the offending TS line -(computed from the oxc diagnostic's span). +`getJsName() === 'SyntaxError'` and `getLine()` pointing at the offending source +line. With transpilation, the location comes from the Oxc diagnostic's span; +with direct JavaScript, it comes from QuickJS's original stack. ### Resource limits -An infinite loop or over-budget script raises `QuickJSTimeoutException`; an -allocation bomb raises `QuickJSMemoryException`. These fire from the interrupt -handler / allocator and so carry no meaningful source location. The engine -recovers and remains usable afterward. +An infinite loop or over-budget eval, callback or job batch raises +`QuickJSTimeoutException`. An allocation failure from `eval()` raises +`QuickJSMemoryException`; callbacks and jobs retain their existing +`QuickJSEvalException` mapping for memory errors. Resource errors carry no +meaningful source location. The engine recovers and remains usable afterward. ## PHP exception → JS diff --git a/docs/execution-modes.md b/docs/execution-modes.md index 0d7570f..a3f38c7 100644 --- a/docs/execution-modes.md +++ b/docs/execution-modes.md @@ -14,10 +14,11 @@ See [`examples/modes.php`](../examples/modes.php) for the two side by side. A QuickJS **`Context`** is a *realm*: its own `globalThis`, its own intrinsics (`Array`, `JSON`, …), and its own top-level scope. The **`Runtime`** (heap, GC, -memory limit, interrupt handler) is separate and owned once per `QuickJS` -instance (`engine.rs`). +memory limit, interrupt handler, and Promise job queue) is separate. Shared mode +keeps one runtime per instance; isolated mode creates and drops a runtime for each +outer `eval()`. Nested callbacks reuse the active runtime. -The only difference between the modes is **realm lifecycle**: +The modes differ in **runtime and realm lifecycle**: | | Shared (default) | Isolated (`isolated: true`) | |---|---|---| @@ -29,7 +30,8 @@ The only difference between the modes is **realm lifecycle**: | Capability handles | work | work | | Synchronous callbacks (within an eval) | work | work | | JS callback stored in PHP, called later | works | **throws** (realm gone) | -| Memory limit | shared across evals | shared across evals (same `Runtime`) | +| Memory limit | shared across evals | applied separately to each eval | +| Detached Promise jobs | remain queued | discarded when eval ends | ## Shared mode — a persistent session @@ -55,7 +57,8 @@ on `globalThis` for the next eval to read. ## Isolated mode — a stateless runner -A fresh realm per `eval()`, discarded afterward. Think **independent script +A fresh runtime and realm per `eval()`, discarded afterward. A returned Promise +is awaited; any remaining detached jobs are discarded without being executed. Think **independent script runner**: every eval is hermetic. ```php @@ -87,7 +90,8 @@ The behavioral split comes down to **what lives in the realm vs. host-side**: modes behave identically there. - Guest globals and the **JS function registry** (`jsFns`, in `runtime.js`) live **in the realm** → shared mode keeps them, isolated mode drops them with the - realm. That single fact is the entire difference. + realm. Detached Promise jobs live in the runtime, which isolated mode also + discards. ## The JS callback registry: persistence and cleanup @@ -98,7 +102,7 @@ holds only the integer id (in a `Js\Callback`). Two requirements pull against each other: 1. **Persist across evals (shared mode).** The bridge is (re)installed every - eval; `runtime.js` is therefore guarded (`if (!globalThis.__rt) …`) so a + eval; `runtime.js` is therefore guarded (`if (!globalThis.__registerJsFn) …`) so a re-install does **not** recreate `jsFns`. The registry survives for the realm's life, so a stored callback keeps working after later evals. @@ -106,7 +110,7 @@ each other: garbage-collected. But deletion can't happen *eagerly* in `Drop`: a JS function can round-trip PHP→JS within a single host call (e.g. `php.identity(fn)` returns `fn` straight back to JS), which drops a transient wrapper while JS - still needs the entry — deleting then would race the unwrap. So `Drop` only + still needs the entry — deleting then would race reconstruction. So `Drop` only **queues** the id (touching no JS, no locks), and the queued ids are flushed at the **next eval boundary**, when no round-trip is in flight. @@ -123,5 +127,14 @@ registry is reclaimed shortly after PHP lets go. In isolated mode the whole real no collisions, automatic per-eval cleanup). Caveat: don't stash a JS callback in PHP to fire after the eval — pass it and use it *within* the eval. - **Strongest isolation:** a brand-new `QuickJS` per tenant. That gives a fresh - `Runtime` too — a separate heap and its own memory limit — not just a fresh - realm. + host-side capability registrations and handles too. Isolated mode already + gives each eval a separate heap and memory limit. + +## JavaScript without transpilation + +`eval(string $code, bool $typescript = true)` uses Oxc and source maps by +default. For JavaScript that is already ready to execute, pass +`$js->eval($source, typescript: false)` to skip Oxc and its transpile cache. +Both paths use the same bridge, Promise awaiting and resource limits, in both +execution modes. JavaScript errors retain their original `guest.js` coordinates; +TypeScript errors are remapped to `guest.ts`. diff --git a/docs/install.md b/docs/install.md index 3afc52c..8ab2f5c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -19,6 +19,26 @@ Releases attach these artifacts (per PHP 8.4 / 8.5, NTS): ## Self-hosted (Linux / macOS / Docker) +For local development without installing Rust, PHP headers, or Composer on the +host, use the included development image: + +```sh +docker compose run --rm --build dev +docker compose run --rm --build dev make release +``` + +`Dockerfile-dev` pins PHP 8.5 and Rust 1.99.1. Compose mounts the checkout at +`/workspace`, writes build artifacts to `target/docker`, and keeps Cargo and +Composer downloads in `.cache/cargo` and `.cache/composer` (ignored by Git). +It creates no Docker-managed volumes. + +The default command runs the test suite after installing Composer dependencies. +Pass another command to run tools in the same environment, for example: + +```sh +docker compose run --rm dev cargo fmt --check +``` + Download the `.so`/`.dylib` matching your PHP version and arch, then enable it: ```ini @@ -104,12 +124,17 @@ extension=/var/task/quickjs.so ## Build from source -Requires Rust 1.96+, clang, and PHP 8.4/8.5 dev headers (`php-config`). +Requires Rust 1.97+, clang, and PHP 8.4/8.5 dev headers (`php-config`). ```sh -git clone https://github.com/eddmann/php-quickjs && cd php-quickjs -make release # -> target/release/libphp_quickjs.so (or .dylib on macOS) -make test # optional: Rust unit tests + PHP suite +git clone https://github.com/xtrime-ru/php-quickjs && cd php-quickjs +# Check out a revision containing the worker before building. +export PHP="$(command -v php)" PHP_CONFIG="$(command -v php-config)" +cargo build --release # -> target/release/libphp_quickjs.so (or .dylib on macOS) +cargo test --lib +# Do not export PHP when invoking Makefile: it adds the extension flag itself. +unset PHP +make test-php PROFILE=release ``` To build a Lambda-compatible binary locally, build inside the Bref image so it diff --git a/docs/transpile-cache.md b/docs/transpile-cache.md new file mode 100644 index 0000000..9e21f10 --- /dev/null +++ b/docs/transpile-cache.md @@ -0,0 +1,17 @@ +# Transpile cache + +TypeScript evaluation caches Oxc output in a content-addressed LRU. Each engine +defaults to retaining at most 256 entries and 32 MiB of string bytes across their original +sources, generated JavaScript, and source maps. Cache hits reuse the generated +strings and update recency. Oldest entries are evicted until both limits hold. + +An entry larger than the configured byte budget is transpiled and executed without caching. Replacing +an entry adjusts its byte accounting; the original source is checked on every +hash hit so a collision cannot return another guest's JavaScript. + +The budget covers strings retained by the cache, not all Oxc memory, temporary +transpilation allocations, QuickJS heap, or output strings still held by an +active evaluation after cache eviction. It is independent of the message queue +limit. Constructor arguments `transpileCacheMaxBytes` (default 33554432) and +`transpileCacheMaxEntries` (default 256) configure these bounds per instance. +Both must be non-negative; `0` in either argument disables caching. diff --git a/rust-toolchain.toml b/rust-toolchain.toml index e3bc9b0..fc34a8a 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,6 +1,6 @@ [toolchain] -# 1.96+ required: oxc 0.137 uses stabilized `if let` guards. -channel = "1.96" +# Rust 1.97+ required by oxc 0.155. +channel = "1.99.1" # Pin rustfmt here so any toolchain rustup resolves from this file (e.g. when # `cargo fmt` triggers an install) always has the component — CI's separate # component install can land on a different toolchain resolution otherwise. diff --git a/src/bridge.rs b/src/bridge.rs index d483ccb..d51d136 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -1,27 +1,19 @@ -//! The trust boundary: one `__host` import into JS, a flat dispatch table of -//! registered PHP callables, and the frozen `php.*` facade generated from the -//! manifest. -//! -//! The `__host(name, argsBytes)` ABI is byte-based: the guest encodes its -//! argument array to msgpack, the host decodes it, dispatches to the PHP -//! callable, and returns the msgpack-encoded result. Adding a capability never -//! changes this ABI. +//! The trust boundary: native host imports dispatch registered PHP callables +//! through a flat allowlist and the frozen `php.*` facade. -use crate::engine::{push_ctx, Engine}; -use crate::error::{throw_host_error, HostError}; +use crate::engine::Engine; +use crate::error::HostError; use crate::handles::HandleTable; use crate::manifest::ManifestEntry; use crate::marshal::{middle_to_zval, zval_to_middle, MiddleValue}; use ext_php_rs::convert::IntoZvalDyn; use ext_php_rs::types::{ZendCallable, Zval}; -use rquickjs::{Ctx, Exception, Function, TypedArray, Value}; use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::{Rc, Weak}; -/// The msgpack codec and runtime support injected into every sandbox context. -const MSGPACK_JS: &str = include_str!("js/msgpack.js"); -const RUNTIME_JS: &str = include_str!("js/runtime.js"); +/// Runtime support injected into each context. +pub(crate) const RUNTIME_JS: &str = include_str!("js/runtime.js"); /// Shared host-side state behind the bridge. Single-threaded (PHP NTS), so /// `Rc`/`RefCell` interior mutability is sufficient and correct. @@ -31,6 +23,7 @@ pub struct BridgeState { dispatch: RefCell>, /// Ordered registration manifest (drives the facade and the `.d.ts`). manifest: RefCell>, + manifest_revision: Cell, /// Anonymous PHP callables handed to JS, keyed by id. php_funcs: RefCell>, next_php: Cell, @@ -45,7 +38,7 @@ pub struct BridgeState { impl BridgeState { pub fn new() -> Rc { - Rc::new(Self::default()) + Rc::new(Self { ..Self::default() }) } /// Queue a JS-callback id for release at the next eval boundary. @@ -53,7 +46,7 @@ impl BridgeState { self.pending_fn_deletions.borrow_mut().push(id); } - fn take_pending_deletions(&self) -> Vec { + pub(crate) fn take_pending_deletions(&self) -> Vec { std::mem::take(&mut self.pending_fn_deletions.borrow_mut()) } @@ -75,7 +68,8 @@ impl BridgeState { if !callable.is_callable() { return Err(format!("value registered as '{name}' is not callable")); } - self.dispatch + let previous = self + .dispatch .borrow_mut() .insert(name.clone(), callable.shallow_clone()); let mut manifest = self.manifest.borrow_mut(); @@ -84,6 +78,9 @@ impl BridgeState { } else { manifest.push(ManifestEntry { name, types }); } + drop(manifest); + self.manifest_revision.set(self.manifest_revision.get() + 1); + drop(previous); Ok(()) } @@ -97,6 +94,16 @@ impl BridgeState { id } + pub(crate) fn release_php_fns(&self, ids: &[u64]) { + // Drop captured PHP values after releasing the RefCell borrow: PHP + // destructors may call back into the extension. + let removed: Vec<_> = { + let mut functions = self.php_funcs.borrow_mut(); + ids.iter().filter_map(|id| functions.remove(id)).collect() + }; + drop(removed); + } + pub fn get_php_fn(&self, id: u64) -> Option { self.php_funcs.borrow().get(&id).map(Zval::shallow_clone) } @@ -105,17 +112,23 @@ impl BridgeState { self.manifest.borrow().clone() } - fn names(&self) -> Vec { - self.manifest - .borrow() - .iter() - .map(|e| e.name.clone()) - .collect() + pub fn manifest_revision(&self) -> u64 { + self.manifest_revision.get() + } + + pub fn clear(&self) { + let dispatch = std::mem::take(&mut *self.dispatch.borrow_mut()); + let functions = std::mem::take(&mut *self.php_funcs.borrow_mut()); + self.manifest.borrow_mut().clear(); + self.pending_fn_deletions.borrow_mut().clear(); + self.handles.clear(); + drop(dispatch); + drop(functions); } } /// Invoke a PHP callable with already-marshaled args, returning its result. -fn call_php( +pub(crate) fn call_php( callable_zv: &Zval, args: &[MiddleValue], state: &BridgeState, @@ -137,7 +150,7 @@ fn call_php( /// Dispatch a named host call. Returns `Err` for an unknown capability (the /// trust-boundary rejection) or a failed call. -fn host_call( +pub(crate) fn host_call( state: &BridgeState, name: &str, args: Vec, @@ -152,7 +165,7 @@ fn host_call( } /// Invoke an anonymous PHP callable (one previously handed to JS) by id. -fn php_fn_call( +pub(crate) fn php_fn_call( state: &BridgeState, id: u64, args: Vec, @@ -163,95 +176,8 @@ fn php_fn_call( call_php(&callable_zv, &args, state) } -/// Decode the msgpack arg payload from a host import into a list of values. -fn decode_args(bytes: &[u8]) -> Result, String> { - match MiddleValue::from_msgpack(bytes).map_err(|e| e.to_string())? { - MiddleValue::Array(a) => Ok(a), - other => Ok(vec![other]), - } -} - -/// Encode a host result back to a msgpack `Uint8Array` for JS. -fn encode_result<'js>(ctx: &Ctx<'js>, result: MiddleValue) -> rquickjs::Result> { - let out = result - .to_msgpack() - .map_err(|e| Exception::throw_type(ctx, &format!("encode failed: {e}")))?; - Ok(TypedArray::new(ctx.clone(), out)?.into_value()) -} - -/// Install the bridge into a context: the `__host`/`__php_invoke` native -/// imports, the msgpack codec, the runtime support, and the frozen `php.*` -/// facade. Call once per `eval`, before guest code runs. -pub fn install<'js>(ctx: &Ctx<'js>, state: Rc) -> rquickjs::Result<()> { - let globals = ctx.globals(); - - // The single JS -> host capability entry point. - let host_state = state.clone(); - let host = Function::new( - ctx.clone(), - move |ctx: Ctx<'js>, - name: String, - args_bytes: TypedArray<'js, u8>| - -> rquickjs::Result> { - let bytes = args_bytes - .as_bytes() - .ok_or_else(|| Exception::throw_type(&ctx, "__host args must be a Uint8Array"))?; - let args = decode_args(bytes).map_err(|e| Exception::throw_type(&ctx, &e))?; - let result = { - let _guard = push_ctx(&ctx); - host_call(&host_state, &name, args) - }; - match result { - Ok(r) => encode_result(&ctx, r), - Err(err) => Err(throw_host_error(&ctx, &err)), - } - }, - )?; - globals.set("__host", host)?; - - // JS -> host: invoke an anonymous PHP callable handed to JS earlier. - let php_state = state.clone(); - let php_invoke = Function::new( - ctx.clone(), - move |ctx: Ctx<'js>, - id: f64, - args_bytes: TypedArray<'js, u8>| - -> rquickjs::Result> { - let bytes = args_bytes.as_bytes().ok_or_else(|| { - Exception::throw_type(&ctx, "__php_invoke args must be a Uint8Array") - })?; - let args = decode_args(bytes).map_err(|e| Exception::throw_type(&ctx, &e))?; - let result = { - let _guard = push_ctx(&ctx); - php_fn_call(&php_state, id as u64, args) - }; - match result { - Ok(r) => encode_result(&ctx, r), - Err(err) => Err(throw_host_error(&ctx, &err)), - } - }, - )?; - globals.set("__php_invoke", php_invoke)?; - - // Codec, runtime support, then the frozen facade. - ctx.eval::<(), _>(MSGPACK_JS)?; - ctx.eval::<(), _>(RUNTIME_JS)?; - ctx.eval::<(), _>(build_facade(&state.names()))?; - - // Release JS callbacks whose PHP wrappers were dropped since the last eval. - let stale = state.take_pending_deletions(); - if !stale.is_empty() { - if let Ok(del) = globals.get::<_, Function>("__deleteJsFn") { - for id in stale { - let _ = del.call::<_, ()>((id as f64,)); - } - } - } - Ok(()) -} - /// Generate the JS that builds the frozen `php.*` tree from the dotted names. -fn build_facade(names: &[String]) -> String { +pub(crate) fn build_facade(names: &[String]) -> String { let mut src = String::from( "(function(){\n\ var php = {};\n", @@ -267,7 +193,7 @@ fn build_facade(names: &[String]) -> String { } let leaf = format!("{path}[{}]", js_string(parts[parts.len() - 1])); src.push_str(&format!( - "{leaf} = function(){{ return globalThis.__rt.callHost({}, Array.prototype.slice.call(arguments)); }};\n", + "{leaf} = function(){{ return globalThis.__host({}, Array.prototype.slice.call(arguments)); }};\n", js_string(name) )); } @@ -294,7 +220,54 @@ mod tests { let src = build_facade(&["db.query".into(), "log.info".into()]); assert!(src.contains("php[\"db\"] = php[\"db\"] || {};")); assert!(src.contains("php[\"db\"][\"query\"] = function()")); - assert!(src.contains("callHost(\"db.query\"")); + assert!(src.contains("__host(\"db.query\"")); assert!(src.contains("Object.freeze")); } } + +pub const DEFAULT_MAX_QUEUED_MESSAGE_BYTES: usize = 32 * 1024 * 1024; +const MESSAGE_OVERHEAD: usize = 128; + +/// Detached data snapshots waiting for a PHP drain. +#[derive(Default)] +pub(crate) struct MessageQueue { + messages: Vec, + bytes: usize, + limit: usize, +} +impl MessageQueue { + pub(crate) fn new(limit: usize) -> Self { + Self { + messages: Vec::new(), + bytes: 0, + limit, + } + } + + pub(crate) fn push(&mut self, value: MiddleValue, bytes: usize) -> Result<(), &'static str> { + let total = self + .bytes + .saturating_add(bytes) + .saturating_add(MESSAGE_OVERHEAD); + if total > self.limit { + return Err("message queue limit exceeded"); + } + self.bytes = total; + self.messages.push(value); + Ok(()) + } + + pub(crate) fn drain(&mut self) -> Vec { + self.bytes = 0; + std::mem::take(&mut self.messages) + } + pub(crate) fn is_empty(&self) -> bool { + self.messages.is_empty() + } + + pub(crate) fn remaining(&self) -> usize { + self.limit + .saturating_sub(self.bytes) + .saturating_sub(MESSAGE_OVERHEAD) + } +} diff --git a/src/callback.rs b/src/callback.rs index de644e5..0b1715d 100644 --- a/src/callback.rs +++ b/src/callback.rs @@ -1,107 +1,58 @@ -//! `Js\Callback`: a PHP-facing wrapper around a JS function handed to PHP. -//! -//! The JS function itself never leaves the engine; the callback holds only an -//! integer id into the JS-side registry plus a handle back to the engine. When -//! invoked from PHP it re-enters JS — reusing the live context if a host call -//! is already in flight, else acquiring the runtime lock afresh. - -use crate::engine::{current_ctx_ptr, Engine}; -use crate::marshal::{middle_to_zval, zval_to_middle, MiddleValue}; -use ext_php_rs::prelude::*; -use ext_php_rs::types::Zval; -use rquickjs::{Ctx, Function, TypedArray, Value}; +//! PHP proxy for a function which remains owned by the JavaScript worker. +use crate::{engine::Engine, marshal::arguments_to_middle}; +use ext_php_rs::{prelude::*, types::Zval}; use std::rc::Rc; - #[php_class] #[php(name = "Js\\Callback")] pub struct JsCallback { pub id: u64, + realm_id: u64, pub engine: Rc, } - impl JsCallback { pub fn new(id: u64, engine: Rc) -> Self { - JsCallback { id, engine } + Self { + id, + realm_id: engine.realm_id(), + engine, + } + } + pub fn check_realm(&self) -> Result<(), String> { + if self.realm_id != self.engine.realm_id() + || (self.engine.isolated() && !self.engine.is_active()) + { + return Err("JS callback belongs to an ended isolated eval".into()); + } + Ok(()) } } - impl Drop for JsCallback { - /// Queue this callback's registry entry for release. Deletion is deferred - /// (not done here) and flushed at the next eval boundary: a JS function can - /// be round-tripped PHP->JS within a single host call, dropping a transient - /// wrapper while JS still needs the entry, so deleting eagerly would race. - /// Queuing touches no JS and cannot re-enter the engine. fn drop(&mut self) { - self.engine.state.queue_fn_deletion(self.id); - } -} - -impl JsCallback { - /// Invoke the underlying JS function with the given (already PHP-side) args. - fn invoke_inner(&self, args: &[&Zval]) -> PhpResult { - let _guard = self.engine.enter().map_err(PhpException::default)?; - - let mut middle_args = Vec::with_capacity(args.len()); - for a in args { - middle_args.push(zval_to_middle(a, &self.engine.state).map_err(PhpException::default)?); - } - let payload = MiddleValue::Array(middle_args) - .to_msgpack() - .map_err(|e| PhpException::default(e.to_string()))?; - let id = self.id; - let engine = self.engine.clone(); - - let run = move |ctx: &Ctx<'_>| -> PhpResult { - let globals = ctx.globals(); - let invoke: Function = globals - .get("__invokeJs") - .map_err(|e| PhpException::default(format!("__invokeJs missing: {e}")))?; - let arg_bytes = TypedArray::new(ctx.clone(), payload.clone()) - .map_err(|e| PhpException::default(e.to_string()))?; - // A JS error here re-surfaces a host exception (unwrapped to its - // original PHP class) or becomes a QuickJSEvalException. - let ret: Value = invoke - .call((id as f64, arg_bytes)) - .map_err(|e| crate::error::js_error_to_php(ctx, e))?; - let ta = TypedArray::::from_value(ret).map_err(|e| { - PhpException::default(format!("JS callback did not return bytes: {e}")) - })?; - let bytes = ta - .as_bytes() - .ok_or_else(|| PhpException::default("detached result buffer".to_owned()))?; - let mv = MiddleValue::from_msgpack(bytes) - .map_err(|e| PhpException::default(e.to_string()))?; - middle_to_zval(&mv, &engine.state).map_err(PhpException::default) - }; - - // Reuse the live context if we are nested inside a host call; otherwise - // acquire the runtime lock on the persistent realm. Reusing avoids a - // deadlock from re-locking. - match current_ctx_ptr() { - Some(ptr) => { - let ctx = unsafe { Ctx::from_raw(ptr) }; - run(&ctx) - } - None => match self.engine.shared_ctx() { - Some(ctx) => ctx.with(|c| run(&c)), - // Isolated mode: the realm that owned this callback is gone. - None => Err(PhpException::default( - "JS callback invoked outside its eval (isolated QuickJS instance)".to_owned(), - )), - }, + if self.check_realm().is_ok() { + self.engine.state.queue_fn_deletion(self.id) } } } - #[php_impl] impl JsCallback { - /// Invoke the JS callback: `$cb(...$args)`. pub fn __invoke(&self, args: &[&Zval]) -> PhpResult { - self.invoke_inner(args) + self.check_realm().map_err(PhpException::from_message)?; + self.engine.ensure_open()?; + let _guard = self.engine.enter().map_err(PhpException::from_message)?; + let args = + arguments_to_middle(args, &self.engine.state).map_err(PhpException::from_message)?; + self.engine.callback(self.id, args) } - - /// Explicit form: `$cb->call([...$args])`. pub fn call(&self, args: &[&Zval]) -> PhpResult { - self.invoke_inner(args) + self.__invoke(args) + } + /// Queue a call, discard its value and report failures to awaitMessages(). + /// Admission is bounded and yields through Revolt when the queue is full. + pub fn post(&self, args: &[&Zval]) -> PhpResult<()> { + self.check_realm().map_err(PhpException::from_message)?; + self.engine.ensure_post_open()?; + let args = + arguments_to_middle(args, &self.engine.state).map_err(PhpException::from_message)?; + self.engine.post(self.id, args) } } diff --git a/src/engine.rs b/src/engine.rs index 2ca53af..82a14d1 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,172 +1,661 @@ -//! Owns the QuickJS runtime/context and the re-entrancy machinery. - -use crate::bridge::BridgeState; -use crate::sandbox; -use crate::transpile::TranspileCache; -use rquickjs::{Context, Ctx, Runtime}; -use std::cell::Cell; -use std::ptr::NonNull; -use std::rc::Rc; -use std::time::{Duration, Instant}; - -/// Maximum nesting depth across the JS<->PHP boundary, guarding runaway mutual -/// recursion (which would otherwise overflow the native stack). +//! PHP-thread facade. Only owned wire values cross into the JavaScript thread. +use crate::{ + bridge::BridgeState, + marshal::{middle_to_zval, MiddleValue}, + worker::{self, Command, Config, Event, Operation, Request, WireError}, +}; +use ext_php_rs::{ + closure::Closure, + convert::{IntoZval, IntoZvalDyn}, + prelude::*, + types::Zval, + zend::{ExecutorGlobals, Function as PhpFunction}, +}; +use std::{ + cell::{Cell, OnceCell, RefCell}, + collections::{HashMap, VecDeque}, + io::Read, + rc::{Rc, Weak}, +}; pub const MAX_DEPTH: usize = 200; -/// The QuickJS engine: runtime + context + the shared bridge state. pub struct Engine { - pub rt: Runtime, pub state: Rc, - /// Content-addressed TS->JS transpile cache (source maps kept host-side). - pub transpile: TranspileCache, - /// The persistent realm in shared mode; `None` in isolated mode (a fresh - /// realm is created per eval and discarded afterwards). - shared_ctx: Option, + port: worker::Port, + isolated: bool, + realm: Cell, + sequence: Cell, + active_eval: Cell>, + root_calls: Cell, + host_calls: Cell, depth: Cell, - /// Per-eval wall-clock deadline; `None` when no eval is in flight. - deadline: Rc>>, - /// Set by the interrupt handler when it aborts on the deadline. - timed_out: Rc>, - /// Per-eval timeout; `None` disables the wall-clock guard. - timeout: Option, + results: RefCell>>, + waiters: RefCell>>, + events: RefCell>, + watcher: RefCell>, + watcher_referenced: Cell, + manifest_revision: Cell>, + message_waiter: Cell>, + message_error: RefCell>, + loop_functions: RefCell>, + weak: Weak, +} +struct Waiter { + suspension: OnceCell, + sleeping: Cell, +} +unsafe extern "C" { + fn php_quickjs_notification_stream(fd: i32, result: *mut Zval) -> bool; +} +impl Waiter { + fn wake(&self) -> PhpResult<()> { + if self.sleeping.replace(false) { + checked( + self.suspension + .get() + .expect("sleeping waiter") + .try_call_method("resume", vec![]), + )?; + } + Ok(()) + } +} +fn checked(result: ext_php_rs::error::Result) -> PhpResult { + let value = result?; + // Leave Zend's exception in place, including uncatchable Fiber shutdown. + // PhpException::throw preserves it when control returns to PHP. + if ExecutorGlobals::has_exception() { + return Err(PhpException::from_message("PHP callback failed".into())); + } + Ok(value) +} +pub(crate) fn call_php(class: &str, method: &str, args: Vec<&dyn IntoZvalDyn>) -> PhpResult { + checked( + PhpFunction::try_from_method(class, method) + .ok_or_else(|| { + PhpException::from_message(format!( + "{class}::{method} unavailable; autoload revolt/event-loop" + )) + })? + .try_call(args), + ) +} +fn function(name: &str, args: Vec<&dyn IntoZvalDyn>) -> PhpResult { + checked( + PhpFunction::try_from_function(name) + .ok_or_else(|| PhpException::from_message(format!("PHP function {name} unavailable")))? + .try_call(args), + ) } - impl Engine { - pub fn new( - memory_limit: usize, - timeout_ms: u64, - max_stack: usize, - isolated: bool, - ) -> rquickjs::Result> { - let rt = Runtime::new()?; - sandbox::apply_limits(&rt, memory_limit, max_stack); - let deadline = Rc::new(Cell::new(None)); - let timed_out = Rc::new(Cell::new(false)); - sandbox::install_interrupt(&rt, deadline.clone(), timed_out.clone()); - - // Shared mode: one persistent realm. Isolated mode: a fresh realm per - // eval (so each eval is its own world; cross-eval state is not kept). - let shared_ctx = if isolated { - None - } else { - Some(Context::full(&rt)?) - }; + pub fn new(config: Config) -> PhpResult> { let state = BridgeState::new(); - let engine = Rc::new(Engine { - rt, + let isolated = config.isolated; + let port = worker::Port::new(config).map_err(WireError::into_php)?; + let engine = Rc::new_cyclic(|weak| Self { state: state.clone(), - transpile: TranspileCache::new(256), - shared_ctx, + port, + isolated, + realm: Cell::new(0), + sequence: Cell::new(0), + active_eval: Cell::new(None), + root_calls: Cell::new(0), + host_calls: Cell::new(0), depth: Cell::new(0), - deadline, - timed_out, - timeout: (timeout_ms > 0).then(|| Duration::from_millis(timeout_ms)), + results: RefCell::new(HashMap::new()), + waiters: RefCell::new(HashMap::new()), + events: RefCell::new(VecDeque::new()), + watcher: RefCell::new(None), + watcher_referenced: Cell::new(false), + manifest_revision: Cell::new(None), + message_waiter: Cell::new(None), + message_error: RefCell::new(None), + loop_functions: RefCell::new(HashMap::new()), + weak: weak.clone(), }); - // Close the cycle so the bridge can reach back into the engine when it - // needs to invoke JS callbacks held by PHP. state.set_engine(Rc::downgrade(&engine)); Ok(engine) } - - /// Arm the wall-clock deadline for an eval and clear the timeout flag. - pub fn arm_deadline(&self) { - self.timed_out.set(false); - self.deadline.set(self.timeout.map(|t| Instant::now() + t)); + pub fn close(&self) -> PhpResult<()> { + self.port.check_pid().map_err(WireError::into_php)?; + self.port.close(); + let result = self.wake_all(); + // No user destructor runs until every sleeping caller has been resumed. + self.remove_watcher(); + self.events.borrow_mut().clear(); + self.results.borrow_mut().clear(); + let _ = self.port.drain_messages(); + self.state.clear(); + result } - - /// Disarm the deadline once an eval completes. - pub fn disarm_deadline(&self) { - self.deadline.set(None); + fn loop_function(&self, method: &'static str) -> Option { + if let Some(function) = self.loop_functions.borrow().get(method).copied() { + return Some(function); + } + let function = PhpFunction::try_from_method("Revolt\\EventLoop", method)?; + self.loop_functions.borrow_mut().insert(method, function); + Some(function) } - - /// Whether the last eval was aborted by the wall-clock guard. - pub fn timed_out(&self) -> bool { - self.timed_out.get() + fn loop_call(&self, method: &'static str, args: Vec<&dyn IntoZvalDyn>) -> PhpResult { + checked( + self.loop_function(method) + .ok_or_else(|| PhpException::from_message("Autoload revolt/event-loop".into()))? + .try_call(args), + ) } - - /// The persistent realm, if this engine has one (shared mode). - pub fn shared_ctx(&self) -> Option<&Context> { - self.shared_ctx.as_ref() + pub fn ensure_open(&self) -> PhpResult<()> { + self.port.check().map_err(WireError::into_php) } - - /// Run `f` inside an eval realm: the persistent one in shared mode, or a - /// fresh, single-use realm in isolated mode. The realm's `Ctx` is published - /// on the current-context stack for the duration so PHP-side callbacks - /// (and the GC `Drop` cleanup) re-use it instead of re-locking the runtime. - pub fn eval_in(&self, f: impl FnOnce(&Ctx<'_>) -> R) -> rquickjs::Result { - fn run(ctx: &Context, f: impl FnOnce(&Ctx<'_>) -> R) -> R { - ctx.with(|c| { - let _guard = push_ctx(&c); - f(&c) - }) - } - match &self.shared_ctx { - Some(ctx) => Ok(run(ctx, f)), - None => { - let ctx = Context::full(&self.rt)?; - Ok(run(&ctx, f)) - } - } + pub fn ensure_post_open(&self) -> PhpResult<()> { + self.port + .check() + .map_err(|error| self.error_after_stop(error)) } - - /// Enter one level of cross-boundary nesting; errors if the cap is hit. - pub fn enter(&self) -> Result, String> { + pub fn isolated(&self) -> bool { + self.isolated + } + pub fn realm_id(&self) -> u64 { + self.realm.get() + } + pub fn is_active(&self) -> bool { + self.active_eval.get().is_some() || self.root_calls.get() > 0 || self.host_calls.get() > 0 + } + pub fn enter(&self) -> Result, String> { let d = self.depth.get(); if d >= MAX_DEPTH { return Err(format!( "maximum bridge re-entrancy depth ({MAX_DEPTH}) exceeded" )); } - self.depth.set(d + 1); - Ok(DepthGuard { depth: &self.depth }) + Ok(CounterGuard::enter(&self.depth)) + } + pub fn eval(&self, code: String, typescript: bool) -> PhpResult { + if self.is_active() { + return Err(PhpException::from_message( + "Cannot eval while JavaScript is executing; use a JS callback".into(), + )); + } + if self.isolated { + self.realm.set(self.realm.get() + 1) + } + self.request(Operation::Eval { code, typescript }) + } + pub fn callback(&self, id: u64, args: Vec) -> PhpResult { + self.request(Operation::Call { + id, + args, + posted: false, + }) + } + pub fn post(&self, function: u64, args: Vec) -> PhpResult<()> { + if self.isolated { + return Err(PhpException::from_message( + "post requires a non-isolated runtime".into(), + )); + } + self.port + .start_pump() + .map_err(|error| self.error_after_stop(error))?; + while !self.port.reserve_post() { + if self.host_calls.get() > 0 { + return Err(PhpException::from_message( + "Post admission is full during a synchronous PHP host callback".into(), + )); + } + if let Err(error) = self.request(Operation::Capacity) { + if let Err(stopped) = self.port.check() { + return Err(self.error_after_stop(stopped)); + } + return Err(error); + } + self.port.check().map_err(WireError::into_php)?; + } + let id = self.next_id(); + let result = self.submit( + id, + Operation::Call { + id: function, + args, + posted: true, + }, + self.active_eval.get(), + ); + if result.is_err() { + self.port.release_post(); + } + result + } + pub fn jobs(&self, max: i64) -> PhpResult { + if self.is_active() { + return Err(PhpException::from_message( + "Cannot run jobs while JavaScript is executing".into(), + )); + } + self.request(Operation::Jobs(max)) + } + pub fn has_jobs(&self) -> PhpResult { + self.request(Operation::HasJobs) + } + pub fn roundtrip(&self, value: MiddleValue) -> PhpResult { + self.request(Operation::Roundtrip(value)) + } + pub fn drain_messages(&self) -> PhpResult> { + self.port.drain_messages().map_err(WireError::into_php) + } + pub fn await_messages(&self) -> PhpResult> { + if self.isolated || self.message_waiter.get().is_some() { + return Err(PhpException::from_message( + "awaitMessages requires one consumer on a non-isolated runtime".into(), + )); + } + self.port + .start_pump() + .map_err(|error| self.error_after_stop(error))?; + if let Some(error) = self.message_error.borrow_mut().take() { + return Err(error.into_php()); + } + let ready = self.drain_messages()?; + if !ready.is_empty() { + return Ok(ready); + } + let id = self.next_id(); + self.message_waiter.set(Some(id)); + let result = self.wait( + id, + || Ok(()), + || { + if let Some(error) = self.message_error.borrow_mut().take() { + return Err(error.into_php()); + } + let messages = self.drain_messages()?; + Ok((!messages.is_empty()).then_some(messages)) + }, + ); + self.message_waiter.set(None); + result + } + fn next_id(&self) -> u64 { + let id = self.sequence.get() + 1; + self.sequence.set(id); + id + } + fn wait( + &self, + id: u64, + start: impl FnOnce() -> PhpResult<()>, + mut ready: impl FnMut() -> PhpResult>, + ) -> PhpResult { + if self.waiters.borrow().len() >= 256 { + return Err(PhpException::from_message( + "Too many pending QuickJS operations (256)".into(), + )); + } + let get_suspension = self.loop_function("getSuspension"); + let waiter = get_suspension.map(|_| { + Rc::new(Waiter { + suspension: OnceCell::new(), + sleeping: Cell::new(false), + }) + }); + if let Some(waiter) = &waiter { + self.waiters.borrow_mut().insert(id, waiter.clone()); + } + let result = (|| { + start()?; + loop { + loop { + let event = self.next_event(id)?; + let Some(event) = event else { break }; + self.event(event, waiter.is_some())?; + } + if let Some(value) = ready()? { + return Ok(value); + } + self.ensure_open()?; + if let Some(waiter) = &waiter { + if waiter.suspension.get().is_none() { + let suspension = + checked(get_suspension.expect("event loop").try_call(vec![]))?; + let _ = waiter.suspension.set(suspension); + } + self.ensure_watcher()?; + // Setup may outlive a short worker call. Drain once more before + // entering Revolt; the descriptor covers a later completion. + if let Some(event) = self.next_event(id)? { + self.event(event, true)?; + continue; + } + waiter.sleeping.set(true); + let result = waiter + .suspension + .get() + .expect("initialized suspension") + .try_call_method("suspend", vec![]); + waiter.sleeping.set(false); + checked(result)?; + } else { + self.event( + self.port.receive_blocking().map_err(WireError::into_php)?, + false, + )?; + } + } + })(); + self.waiters.borrow_mut().remove(&id); + if self.waiters.borrow().is_empty() { + self.pause_watcher(); + } + result + } + fn wake(&self, id: Option) -> PhpResult<()> { + let waiter = id.and_then(|id| self.waiters.borrow().get(&id).cloned()); + waiter.map_or(Ok(()), |waiter| waiter.wake()) + } + fn host_waiter(&self, event: &Event) -> Option { + match event { + Event::Host { owner, .. } => owner + .filter(|id| self.waiters.borrow().contains_key(id)) + .or(self.message_waiter.get()), + _ => None, + } + } + fn next_event(&self, caller: u64) -> PhpResult> { + let index = self + .events + .borrow() + .iter() + .position(|event| self.host_waiter(event).is_none_or(|owner| owner == caller)); + if let Some(index) = index { + return Ok(self.events.borrow_mut().remove(index)); + } + while let Ok(event) = self.port.receive() { + if let Some(owner) = self.host_waiter(&event).filter(|owner| *owner != caller) { + self.events.borrow_mut().push_back(event); + self.wake(Some(owner))?; + } else { + return Ok(Some(event)); + } + } + Ok(None) + } + fn finish_host( + &self, + token: u64, + target: worker::HostTarget, + args: Vec, + ) -> PhpResult<()> { + let _host = CounterGuard::enter(&self.host_calls); + let result = match target { + worker::HostTarget::Named(name) => crate::bridge::host_call(&self.state, &name, args), + worker::HostTarget::Function(id) => crate::bridge::php_fn_call(&self.state, id, args), + }; + if let Err(error) = checked(Ok(())) { + self.port.close(); + return Err(error); + } + self.send(Command::HostReply { token, result }) + } + fn queue_host( + &self, + token: u64, + target: worker::HostTarget, + args: Vec, + ) -> PhpResult<()> { + let engine = self.weak.upgrade().expect("active engine"); + let call = RefCell::new(Some((target, args))); + let callback = Closure::wrap(Box::new(move || -> PhpResult<()> { + let Some((target, args)) = call.borrow_mut().take() else { + return Ok(()); + }; + if engine.ensure_open().is_err() { + return Ok(()); + } + engine.finish_host(token, target, args) + }) as Box PhpResult<()>>) + .into_zval(false)?; + let callback = call_php("Closure", "fromCallable", vec![&callback])?; + self.loop_call("queue", vec![&callback])?; + Ok(()) + } + fn wake_all(&self) -> PhpResult<()> { + let waiters = self.waiters.borrow().values().cloned().collect::>(); + let mut error = None; + for waiter in waiters { + if let Err(e) = waiter.wake() { + error.get_or_insert(e); + } + } + error.map_or(Ok(()), Err) + } + fn send(&self, command: Command) -> PhpResult<()> { + self.port.send(command).map_err(WireError::into_php) + } + fn error_after_stop(&self, fallback: WireError) -> PhpException { + if self.port.check_pid().is_err() { + return fallback.into_php(); + } + while let Ok(event) = self.port.receive() { + match event { + Event::Host { .. } | Event::Stopped(_) => {} + event => { + let _ = self.event(event, true); + } + } + } + self.message_error + .borrow_mut() + .take() + .unwrap_or(fallback) + .into_php() + } + fn submit(&self, id: u64, op: Operation, parent: Option) -> PhpResult<()> { + let revision = self.state.manifest_revision(); + let names = (self.isolated || self.manifest_revision.get() != Some(revision)).then(|| { + self.state + .manifest_snapshot() + .into_iter() + .map(|entry| entry.name) + .collect() + }); + self.send(Command::Request(Request { + id, + op, + names, + deletions: self.state.take_pending_deletions(), + parent, + }))?; + self.manifest_revision.set(Some(revision)); + Ok(()) + } + fn request(&self, op: Operation) -> PhpResult { + let id = self.next_id(); + let eval = matches!(&op, Operation::Eval { .. }); + let parent = self.active_eval.get(); + let _root = (!eval && parent.is_none() && !matches!(&op, Operation::HasJobs)) + .then(|| CounterGuard::enter(&self.root_calls)); + if eval { + self.active_eval.set(Some(id)); + } + let result = self.wait( + id, + || self.submit(id, op, parent), + || { + let result = self.results.borrow_mut().remove(&id); + result + .map(|result| { + let data = result.map_err(WireError::into_php)?; + middle_to_zval(&data, &self.state).map_err(PhpException::from_message) + }) + .transpose() + }, + ); + if eval { + self.active_eval.set(None); + } + result + } + fn event(&self, event: Event, has_loop: bool) -> PhpResult<()> { + match event { + Event::Messages => self.wake(self.message_waiter.get())?, + Event::AsyncError(error) | Event::Fatal(error) => { + *self.message_error.borrow_mut() = Some(error); + self.wake(self.message_waiter.get())?; + } + Event::Done { id, result } => { + self.results.borrow_mut().insert(id, result); + self.wake(Some(id))?; + } + Event::Host { + owner, + token, + target, + args, + } => { + if has_loop && !owner.is_some_and(|id| self.waiters.borrow().contains_key(&id)) { + // Background jobs must not borrow a message consumer's + // Fiber: the PHP callback may itself await that consumer. + self.queue_host(token, target, args)?; + } else { + self.finish_host(token, target, args)?; + } + } + Event::NeedsLoop { id } => { + if !has_loop { + self.send(Command::Abort { + id, + message: "Autoload revolt/event-loop to await external I/O".into(), + })?; + } + } + Event::Stopped(message) => return Err(PhpException::from_message(message)), + } + Ok(()) + } + fn ensure_watcher(&self) -> PhpResult<()> { + let watcher = self + .watcher + .borrow() + .as_ref() + .map(|(id, _)| id.shallow_clone()); + if let Some(watcher) = watcher { + if !self.watcher_referenced.replace(true) { + self.loop_call("reference", vec![&watcher])?; + } + return Ok(()); + } + use std::os::fd::AsRawFd; + let mut stream = Zval::new(); + // The C shim uses the PHP streams API and owns a duplicate descriptor. + if !unsafe { php_quickjs_notification_stream(self.port.wake.as_raw_fd(), &mut stream) } { + return Err(PhpException::from_message( + "Cannot open QuickJS notification descriptor".into(), + )); + } + function("stream_set_blocking", vec![&stream, &false])?; + let weak = self.weak.clone(); + let native = Closure::wrap(Box::new(move || -> PhpResult<()> { + if let Some(engine) = weak.upgrade() { + let mut buf = [0u8; 512]; + loop { + match (&engine.port.wake).read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(count) if count < buf.len() => break, + Ok(_) => {} + } + } + engine.port.acknowledge_notification(); + while let Ok(event) = engine.port.receive() { + match event { + Event::Done { .. } + | Event::Messages + | Event::AsyncError(_) + | Event::Fatal(_) => engine.event(event, true)?, + Event::NeedsLoop { .. } => {} + event => engine.events.borrow_mut().push_back(event), + } + } + // A single native notification can carry host calls for several + // Fibers. Wake every owner, otherwise the last nested host call + // may wait forever behind an earlier owner's queued event. + let owners = engine + .events + .borrow() + .iter() + .filter_map(|event| engine.host_waiter(event)) + .collect::>(); + for owner in owners { + engine.wake(Some(owner))?; + } + if !engine.events.borrow().is_empty() + && engine + .events + .borrow() + .iter() + .all(|event| engine.host_waiter(event).is_none()) + { + let waiter = engine + .waiters + .borrow() + .values() + .find(|w| w.sleeping.get()) + .cloned(); + if let Some(waiter) = waiter { + waiter.wake()?; + } + } + if engine.port.check().is_err() { + engine.wake_all()?; + } + } + Ok(()) + }) as Box PhpResult<()>>) + .into_zval(false)?; + let callback = call_php("Closure", "fromCallable", vec![&native])?; + let watcher = self.loop_call("onReadable", vec![&stream, &callback])?; + *self.watcher.borrow_mut() = Some((watcher, stream)); + self.watcher_referenced.set(true); + Ok(()) + } + fn pause_watcher(&self) { + if self.watcher_referenced.replace(false) { + let watcher = self + .watcher + .borrow() + .as_ref() + .map(|(id, _)| id.shallow_clone()); + if let Some(watcher) = watcher { + let _ = self.loop_call("unreference", vec![&watcher]); + } + } + } + fn remove_watcher(&self) { + self.watcher_referenced.set(false); + let watcher = self.watcher.borrow_mut().take(); + if let Some((watcher, stream)) = watcher { + let _ = self.loop_call("cancel", vec![&watcher]); + let _ = function("fclose", vec![&stream]); + } } } - -/// RAII guard that decrements the depth counter on drop. -pub struct DepthGuard<'a> { - depth: &'a Cell, -} - -impl Drop for DepthGuard<'_> { +impl Drop for Engine { fn drop(&mut self) { - self.depth.set(self.depth.get() - 1); + // PHP forbids calling userland after executor shutdown. Its driver and + // stream resources are already being destroyed at that point. + if ExecutorGlobals::get().active { + self.remove_watcher(); + } + self.port.close(); } } - -// --------------------------------------------------------------------------- -// current-context stack -// -// While a host call runs, the live `Ctx` is valid but its `'js` lifetime -// cannot be named in PHP-facing code. We stash the raw pointer so a PHP-held JS -// callback can be invoked *synchronously* during a host call by reusing the -// already-locked context instead of re-locking the runtime (which would -// deadlock). Single-threaded (PHP NTS), so a thread-local stack is sufficient. -// --------------------------------------------------------------------------- - -thread_local! { - static CTX_STACK: std::cell::RefCell>> = - const { std::cell::RefCell::new(Vec::new()) }; -} - -/// Publish the current context on the stack until the returned guard drops. -#[must_use] -pub fn push_ctx(ctx: &Ctx<'_>) -> CtxGuard { - CTX_STACK.with(|s| s.borrow_mut().push(ctx.as_raw())); - CtxGuard +pub struct CounterGuard<'a>(&'a Cell); +impl<'a> CounterGuard<'a> { + fn enter(depth: &'a Cell) -> Self { + depth.set(depth.get() + 1); + Self(depth) + } } - -/// RAII guard that pops the current context when dropped (even on unwind). -pub struct CtxGuard; - -impl Drop for CtxGuard { +impl Drop for CounterGuard<'_> { fn drop(&mut self) { - CTX_STACK.with(|s| { - s.borrow_mut().pop(); - }); + self.0.set(self.0.get() - 1); } } - -/// The innermost active context, if a host call is currently on the stack. -pub fn current_ctx_ptr() -> Option> { - CTX_STACK.with(|s| s.borrow().last().copied()) -} diff --git a/src/error.rs b/src/error.rs index 7fcb09d..0d76eea 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,7 +8,7 @@ use ext_php_rs::error::Error as PhpError; use ext_php_rs::exception::PhpException; -use ext_php_rs::zend::ClassEntry; +use ext_php_rs::zend::{ClassEntry, ExecutorGlobals}; use rquickjs::{Ctx, Error as JsError, Exception, Object}; /// A host-side failure carried back to JS: the originating class (a PHP @@ -32,7 +32,20 @@ impl HostError { /// `Display`, which debug-prints the raw object (NUL-laden memory). pub fn php_exception_info(err: PhpError) -> HostError { match err { - PhpError::Exception(obj) => { + PhpError::ExceptionPending { class } => { + unsafe extern "C" { + fn php_quickjs_is_unwinding() -> bool; + } + // Fiber/request termination must propagate through PHP, not become + // a catchable JS exception or have its special Zend object inspected. + if unsafe { php_quickjs_is_unwinding() } { + return HostError::internal("PHP execution ended"); + } + let Some(obj) = ExecutorGlobals::take_exception() else { + return HostError::internal(format!( + "PHP exception of class {class} is no longer pending" + )); + }; let class = obj .get_class_name() .unwrap_or_else(|_| "Exception".to_owned()); @@ -87,6 +100,28 @@ impl JsErrorParts { } } +/// Shared resource-error detection. Callers retain their existing exception +/// mapping: eval specializes memory errors; callback/jobs keep their old type. +#[derive(Clone, Copy)] +pub enum ResourceError { + Timeout, + Memory, +} + +pub fn resource_error(parts: &JsErrorParts, timed_out: bool) -> Option { + if timed_out { + Some(ResourceError::Timeout) + } else if parts + .display_message() + .to_lowercase() + .contains("out of memory") + { + Some(ResourceError::Memory) + } else { + None + } +} + /// Render an rquickjs error into a human-readable `Name: message` string. pub fn js_error_message(ctx: &Ctx<'_>, err: JsError) -> String { js_error_parts(ctx, err).display_message() @@ -145,8 +180,7 @@ pub fn js_error_parts(ctx: &Ctx<'_>, err: JsError) -> JsErrorParts { /// `phpClass`), the original PHP class is restored with a clean message; /// otherwise it becomes a `QuickJSEvalException`. This unwraps the /// JS->PHP->JS->PHP round trip instead of nesting `Exception:` prefixes. -pub fn js_error_to_php(ctx: &Ctx<'_>, err: JsError) -> PhpException { - let parts = js_error_parts(ctx, err); +pub fn js_error_to_php(parts: JsErrorParts) -> PhpException { if let Some(class) = parts.php_class.as_deref() { if let Some(ce) = ClassEntry::try_find(class) { return PhpException::new(parts.message, 0, ce); @@ -158,7 +192,7 @@ pub fn js_error_to_php(ctx: &Ctx<'_>, err: JsError) -> PhpException { /// Remap a JS stack back to TypeScript coordinates, keeping only guest frames. /// /// Frames that reference `module_id` have their `:line:col` rewritten to the -/// original TS position; internal host frames (the msgpack/runtime bootstrap +/// original TS position; internal host frames (the runtime bootstrap /// and the `php.*` facade wrappers) are dropped so the trace reads like a plain /// TS stack. Returns `None` if the map cannot be parsed or no guest frame /// remains. diff --git a/src/exceptions.rs b/src/exceptions.rs index 9a90c04..24cb2c0 100644 --- a/src/exceptions.rs +++ b/src/exceptions.rs @@ -91,7 +91,7 @@ pub fn eval_exception( } match obj.into_zval(false) { Ok(zval) => { - let mut ex = PhpException::default(message); + let mut ex = PhpException::from_message(message); ex.set_object(Some(zval)); ex } diff --git a/src/handles.rs b/src/handles.rs index 2cd8a20..6974f07 100644 --- a/src/handles.rs +++ b/src/handles.rs @@ -34,9 +34,14 @@ impl HandleTable { /// Drop a handle, releasing the host-side reference. Returns whether it /// existed. pub fn revoke(&self, id: i64) -> bool { - self.map.borrow_mut().remove(&id).is_some() + let value = self.map.borrow_mut().remove(&id); + value.is_some() } + pub fn clear(&self) { + let values = std::mem::take(&mut *self.map.borrow_mut()); + drop(values); + } pub fn len(&self) -> usize { self.map.borrow().len() } diff --git a/src/js/msgpack.js b/src/js/msgpack.js deleted file mode 100644 index b5cc123..0000000 --- a/src/js/msgpack.js +++ /dev/null @@ -1,367 +0,0 @@ -// Minimal MessagePack codec for the php-quickjs __host bridge. -// -// Covers exactly the types crossing the boundary: null, bool, int, float64, -// str (utf-8), bin (Uint8Array), array, and string-keyed map (plain object). -// It must stay byte-compatible with the Rust `MiddleValue` serde impl -// (src/marshal.rs), which reads/writes *native* msgpack types. -// -// Installed once per realm as globalThis.__mp = { encode, decode }. -if (!globalThis.__mp) (function () { - "use strict"; - - // QuickJS has no TextEncoder/TextDecoder (those are WHATWG, not ES), so - // UTF-8 is handled manually here. - function utf8Encode(str) { - var bytes = []; - for (var i = 0; i < str.length; i++) { - var c = str.charCodeAt(i); - if (c < 0x80) { - bytes.push(c); - } else if (c < 0x800) { - bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); - } else if (c >= 0xd800 && c <= 0xdbff) { - // High surrogate; combine with the following low surrogate. - var c2 = str.charCodeAt(++i); - var cp = 0x10000 + ((c & 0x3ff) << 10) + (c2 & 0x3ff); - bytes.push( - 0xf0 | (cp >> 18), - 0x80 | ((cp >> 12) & 0x3f), - 0x80 | ((cp >> 6) & 0x3f), - 0x80 | (cp & 0x3f) - ); - } else { - bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); - } - } - return bytes; - } - - function utf8Decode(bytes, start, len) { - var out = ""; - var i = start; - var end = start + len; - while (i < end) { - var c = bytes[i++]; - if (c < 0x80) { - out += String.fromCharCode(c); - } else if (c < 0xe0) { - out += String.fromCharCode(((c & 0x1f) << 6) | (bytes[i++] & 0x3f)); - } else if (c < 0xf0) { - var b1 = bytes[i++]; - var b2 = bytes[i++]; - out += String.fromCharCode( - ((c & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f) - ); - } else { - var d1 = bytes[i++]; - var d2 = bytes[i++]; - var d3 = bytes[i++]; - var cp = - ((c & 0x07) << 18) | - ((d1 & 0x3f) << 12) | - ((d2 & 0x3f) << 6) | - (d3 & 0x3f); - cp -= 0x10000; - out += String.fromCharCode(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff)); - } - } - return out; - } - - // ---- encoder ---------------------------------------------------------- - function Writer() { - this.bytes = []; - } - Writer.prototype.u8 = function (b) { - this.bytes.push(b & 0xff); - }; - Writer.prototype.u16 = function (n) { - this.u8(n >> 8); - this.u8(n); - }; - Writer.prototype.u32 = function (n) { - this.u8(n >> 24); - this.u8(n >> 16); - this.u8(n >> 8); - this.u8(n); - }; - Writer.prototype.raw = function (arr) { - for (var i = 0; i < arr.length; i++) this.bytes.push(arr[i] & 0xff); - }; - - function encodeInt(w, n) { - if (n >= 0) { - if (n <= 0x7f) { - w.u8(n); - } else if (n <= 0xff) { - w.u8(0xcc); - w.u8(n); - } else if (n <= 0xffff) { - w.u8(0xcd); - w.u16(n); - } else if (n <= 0xffffffff) { - w.u8(0xce); - w.u32(n); - } else { - // uint64 - w.u8(0xcf); - writeBig(w, n); - } - } else { - if (n >= -32) { - w.u8(0xe0 | (n + 32)); - } else if (n >= -128) { - w.u8(0xd0); - w.u8(n & 0xff); - } else if (n >= -32768) { - w.u8(0xd1); - w.u16(n & 0xffff); - } else if (n >= -2147483648) { - w.u8(0xd2); - w.u32(n >>> 0); - } else { - // int64 - w.u8(0xd3); - writeBig(w, n); - } - } - } - - // Write a 64-bit integer (best effort; exact up to 2^53). - function writeBig(w, n) { - var big = BigInt(n); - if (big < 0n) big = (1n << 64n) + big; - for (var i = 7; i >= 0; i--) { - w.u8(Number((big >> BigInt(i * 8)) & 0xffn)); - } - } - - function encodeValue(w, v) { - if (v === null || v === undefined) { - w.u8(0xc0); - } else if (v === true) { - w.u8(0xc3); - } else if (v === false) { - w.u8(0xc2); - } else if (typeof v === "number") { - if (Number.isInteger(v)) { - encodeInt(w, v); - } else { - w.u8(0xcb); - var buf = new ArrayBuffer(8); - new DataView(buf).setFloat64(0, v, false); - w.raw(new Uint8Array(buf)); - } - } else if (typeof v === "bigint") { - // Encode as int64/uint64. - if (v >= 0n) w.u8(0xcf); - else w.u8(0xd3); - writeBig(w, v); - } else if (typeof v === "string") { - var enc = utf8Encode(v); - var len = enc.length; - if (len <= 31) { - w.u8(0xa0 | len); - } else if (len <= 0xff) { - w.u8(0xd9); - w.u8(len); - } else if (len <= 0xffff) { - w.u8(0xda); - w.u16(len); - } else { - w.u8(0xdb); - w.u32(len); - } - w.raw(enc); - } else if (v instanceof Uint8Array) { - var blen = v.length; - if (blen <= 0xff) { - w.u8(0xc4); - w.u8(blen); - } else if (blen <= 0xffff) { - w.u8(0xc5); - w.u16(blen); - } else { - w.u8(0xc6); - w.u32(blen); - } - w.raw(v); - } else if (Array.isArray(v)) { - var alen = v.length; - if (alen <= 15) { - w.u8(0x90 | alen); - } else if (alen <= 0xffff) { - w.u8(0xdc); - w.u16(alen); - } else { - w.u8(0xdd); - w.u32(alen); - } - for (var i = 0; i < alen; i++) encodeValue(w, v[i]); - } else if (typeof v === "object") { - var keys = Object.keys(v); - var mlen = keys.length; - if (mlen <= 15) { - w.u8(0x80 | mlen); - } else if (mlen <= 0xffff) { - w.u8(0xde); - w.u16(mlen); - } else { - w.u8(0xdf); - w.u32(mlen); - } - for (var k = 0; k < mlen; k++) { - encodeValue(w, keys[k]); - encodeValue(w, v[keys[k]]); - } - } else { - throw new TypeError("cannot msgpack-encode value of type " + typeof v); - } - } - - function encode(v) { - var w = new Writer(); - encodeValue(w, v); - return new Uint8Array(w.bytes); - } - - // ---- decoder ---------------------------------------------------------- - function Reader(bytes) { - this.b = bytes; - this.p = 0; - this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - } - Reader.prototype.u8 = function () { - return this.b[this.p++]; - }; - Reader.prototype.u16 = function () { - var n = this.view.getUint16(this.p, false); - this.p += 2; - return n; - }; - Reader.prototype.u32 = function () { - var n = this.view.getUint32(this.p, false); - this.p += 4; - return n; - }; - Reader.prototype.i8 = function () { - var n = this.view.getInt8(this.p); - this.p += 1; - return n; - }; - Reader.prototype.i16 = function () { - var n = this.view.getInt16(this.p, false); - this.p += 2; - return n; - }; - Reader.prototype.i32 = function () { - var n = this.view.getInt32(this.p, false); - this.p += 4; - return n; - }; - Reader.prototype.big = function (signed) { - var hi = BigInt(this.u32()); - var lo = BigInt(this.u32()); - var n = (hi << 32n) | lo; - if (signed && n >= 1n << 63n) n -= 1n << 64n; - // Collapse to a Number when it is exactly representable. - if (n >= -9007199254740991n && n <= 9007199254740991n) return Number(n); - return n; - }; - Reader.prototype.str = function (len) { - var s = utf8Decode(this.b, this.p, len); - this.p += len; - return s; - }; - Reader.prototype.bin = function (len) { - var slice = this.b.slice(this.p, this.p + len); - this.p += len; - return slice; - }; - - function decodeValue(r) { - var c = r.u8(); - if (c <= 0x7f) return c; // positive fixint - if (c >= 0xe0) return c - 256; // negative fixint - if (c >= 0x80 && c <= 0x8f) return decodeMap(r, c & 0x0f); - if (c >= 0x90 && c <= 0x9f) return decodeArray(r, c & 0x0f); - if (c >= 0xa0 && c <= 0xbf) return r.str(c & 0x1f); - switch (c) { - case 0xc0: - return null; - case 0xc2: - return false; - case 0xc3: - return true; - case 0xc4: - return r.bin(r.u8()); - case 0xc5: - return r.bin(r.u16()); - case 0xc6: - return r.bin(r.u32()); - case 0xca: { - var f = r.view.getFloat32(r.p, false); - r.p += 4; - return f; - } - case 0xcb: { - var d = r.view.getFloat64(r.p, false); - r.p += 8; - return d; - } - case 0xcc: - return r.u8(); - case 0xcd: - return r.u16(); - case 0xce: - return r.u32(); - case 0xcf: - return r.big(false); - case 0xd0: - return r.i8(); - case 0xd1: - return r.i16(); - case 0xd2: - return r.i32(); - case 0xd3: - return r.big(true); - case 0xd9: - return r.str(r.u8()); - case 0xda: - return r.str(r.u16()); - case 0xdb: - return r.str(r.u32()); - case 0xdc: - return decodeArray(r, r.u16()); - case 0xdd: - return decodeArray(r, r.u32()); - case 0xde: - return decodeMap(r, r.u16()); - case 0xdf: - return decodeMap(r, r.u32()); - default: - throw new TypeError("unknown msgpack marker 0x" + c.toString(16)); - } - } - - function decodeArray(r, len) { - var out = new Array(len); - for (var i = 0; i < len; i++) out[i] = decodeValue(r); - return out; - } - - function decodeMap(r, len) { - var out = {}; - for (var i = 0; i < len; i++) { - var k = decodeValue(r); - out[k] = decodeValue(r); - } - return out; - } - - function decode(bytes) { - return decodeValue(new Reader(bytes)); - } - - globalThis.__mp = { encode: encode, decode: decode }; -})(); diff --git a/src/js/runtime.js b/src/js/runtime.js index 7545539..805b1d1 100644 --- a/src/js/runtime.js +++ b/src/js/runtime.js @@ -1,129 +1,31 @@ -// Runtime support for bidirectional function passing across the bridge. -// -// Functions cannot be msgpack-encoded, so they cross as tagged refs: -// a JS function -> { "$__jsfn": id } (id into the JS-side registry) -// a PHP callable -> { "$__phpfn": id } (id into the host-side registry) -// -// `wrap` replaces functions with refs before encoding; `unwrap` replaces refs -// with callables after decoding. The host (Rust) sees only the tagged refs. -// -// Installed once per realm: the guard keeps the JS-side function registry -// (`jsFns`) alive across re-installs, so a JS function handed to PHP survives -// for the life of the realm. Entries are freed when their Js\Callback is -// garbage-collected (host calls `__deleteJsFn`). -if (!globalThis.__rt) { - globalThis.__rt = (function () { - "use strict"; - var mp = globalThis.__mp; - var JSFN = "$__jsfn"; - var PHPFN = "$__phpfn"; - - var jsFns = {}; - var nextId = 1; - - function registerFn(fn) { - var id = nextId++; - jsFns[id] = fn; - return id; - } - function getFn(id) { - return jsFns[id]; - } - function deleteFn(id) { - delete jsFns[id]; - } - - function isPlainContainer(v) { - return v && typeof v === "object" && !(v instanceof Uint8Array); - } - - // Replace JS functions with refs (outgoing: JS -> host). - function wrap(v) { - if (typeof v === "function") { - var o = {}; - o[JSFN] = registerFn(v); - return o; - } - if (Array.isArray(v)) return v.map(wrap); - if (isPlainContainer(v)) { - var out = {}; - for (var k in v) { - if (Object.prototype.hasOwnProperty.call(v, k)) out[k] = wrap(v[k]); - } - return out; +// Function references live on their owning side of the native bridge. +// Keep the JS registry across evals in a shared realm; Rust releases entries +// when their Js\Callback wrappers are collected. +if (!globalThis.__registerJsFn) { + (function () { + "use strict"; + var jsFns = {}; + var nextId = 1; + + function registerFn(fn) { + var id = nextId++; + jsFns[id] = fn; + return id; } - return v; - } - - // Replace refs with callables (incoming: host -> JS). - function unwrap(v) { - if (isPlainContainer(v)) { - if (Object.prototype.hasOwnProperty.call(v, PHPFN)) { - return makePhpFn(v[PHPFN]); - } - if (Object.prototype.hasOwnProperty.call(v, JSFN)) { - return getFn(v[JSFN]); - } - if (Array.isArray(v)) return v.map(unwrap); - var out = {}; - for (var k in v) { - if (Object.prototype.hasOwnProperty.call(v, k)) out[k] = unwrap(v[k]); - } - return out; + function makePhpFn(id) { + return function () { + return globalThis.__php_invoke(id, Array.prototype.slice.call(arguments)); + }; } - return v; - } - function makePhpFn(id) { - return function () { - return callPhp(id, Array.prototype.slice.call(arguments)); + globalThis.__registerJsFn = registerFn; + Object.defineProperty(globalThis, "__jsFunctions", {value: jsFns}); + globalThis.__makePhpFn = makePhpFn; + globalThis.__asPromise = function (value) { + return value !== null && + (typeof value === "object" || typeof value === "function") + ? Promise.resolve(value) : null; }; - } - - // JS -> host capability dispatch. - function callHost(name, args) { - return unwrap(mp.decode(globalThis.__host(name, mp.encode(wrap(args))))); - } - - // JS -> host: invoke a PHP callable previously handed to JS. - function callPhp(id, args) { - return unwrap(mp.decode(globalThis.__php_invoke(id, mp.encode(wrap(args))))); - } - - // host -> JS: invoke a JS function previously handed to PHP (called by Rust). - function invokeJs(id, argsBytes) { - var fn = jsFns[id]; - if (!fn) throw new Error("unknown JS callback id " + id); - var args = unwrap(mp.decode(argsBytes)); - var r = fn.apply(null, args); - return mp.encode(wrap(r)); - } - - globalThis.__invokeJs = invokeJs; - // Used by the host to register a bare JS function value (e.g. an eval result - // that is a function) so it can be handed to PHP as a Js\Callback. - globalThis.__registerJsFn = registerFn; - // Used by the host to reconstruct callables when marshaling MiddleValue -> - // JS directly (e.g. QuickJS::roundtrip of a function). - globalThis.__getJsFn = getFn; - globalThis.__makePhpFn = makePhpFn; - // Called when a PHP-side Js\Callback is garbage-collected, to release its - // entry from the registry. - globalThis.__deleteJsFn = deleteFn; - // Test/diagnostic helper: number of live JS callbacks held for PHP. - globalThis.__jsFnCount = function () { - return Object.keys(jsFns).length; - }; - - return { - registerFn: registerFn, - getFn: getFn, - deleteFn: deleteFn, - wrap: wrap, - unwrap: unwrap, - callHost: callHost, - callPhp: callPhp, - invokeJs: invokeJs, - }; + globalThis.__jsFnCount = function () { return Object.keys(jsFns).length; }; })(); } diff --git a/src/lib.rs b/src/lib.rs index 49232a3..e9f32a1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,12 +15,11 @@ mod exceptions; mod handles; mod manifest; mod marshal; -mod sandbox; mod transpile; +mod worker; use engine::Engine; -use exceptions::{QuickJSMemoryException, QuickJSTimeoutException}; -use marshal::{js_to_middle, middle_to_js, middle_to_zval, zval_to_middle}; +use marshal::{middle_to_zval, zval_to_middle}; /// An embedded QuickJS sandbox with a typed, bidirectional PHP bridge. #[php_class] @@ -31,87 +30,114 @@ pub struct QuickJS { #[php_impl] impl QuickJS { - /// Construct a sandbox. All limits default to unbounded; pass non-zero - /// values to contain resource abuse: + /// Construct a sandbox. Heap and time limits default to unbounded: /// - `memoryLimit`: max heap bytes (alloc-bomb guard) - /// - `timeoutMs`: wall-clock budget per `eval` (infinite-loop guard) + /// - `timeoutMs`: wall-clock budget per eval, callback, or job batch /// - `maxStack`: max native stack bytes + /// - `maxQueuedMessageBytes`: maximum accounted bytes waiting in the message queue + /// - `transpileCacheMaxBytes`: retained cache strings (default 32 MiB; 0 disables cache) + /// - `transpileCacheMaxEntries`: retained cache entries (default 256; 0 disables cache) /// - `isolated`: when true, each `eval()` runs in a fresh global realm (its /// own world); cross-eval globals and persistent JS callbacks are not /// kept. Defaults to false (one shared, persistent realm per instance). - #[php(defaults(memoryLimit = None, timeoutMs = None, maxStack = None, isolated = false))] + #[php(defaults(memoryLimit = None, timeoutMs = None, maxStack = None, isolated = false, maxQueuedMessageBytes = None, transpileCacheMaxBytes = 33554432, transpileCacheMaxEntries = 256))] pub fn __construct( memoryLimit: Option, timeoutMs: Option, maxStack: Option, isolated: bool, + maxQueuedMessageBytes: Option, + transpileCacheMaxBytes: i64, + transpileCacheMaxEntries: i64, ) -> PhpResult { - let engine = Engine::new( - memoryLimit.unwrap_or(0).max(0) as usize, - timeoutMs.unwrap_or(0).max(0) as u64, - maxStack.unwrap_or(0).max(0) as usize, + let max_queued_message_bytes = match maxQueuedMessageBytes { + None => bridge::DEFAULT_MAX_QUEUED_MESSAGE_BYTES, + Some(limit) if limit > 0 => usize::try_from(limit).map_err(|_| { + PhpException::from_message("maxQueuedMessageBytes is too large".to_owned()) + })?, + _ => { + return Err(PhpException::from_message( + "maxQueuedMessageBytes must be positive".to_owned(), + )) + } + }; + let transpile_cache_max_bytes = usize::try_from(transpileCacheMaxBytes).map_err(|_| { + PhpException::from_message( + "transpileCacheMaxBytes must be a non-negative integer fitting usize".to_owned(), + ) + })?; + let transpile_cache_max_entries = + usize::try_from(transpileCacheMaxEntries).map_err(|_| { + PhpException::from_message( + "transpileCacheMaxEntries must be a non-negative integer fitting usize" + .to_owned(), + ) + })?; + let engine = Engine::new(worker::Config { + memory_limit: memoryLimit.unwrap_or(0).max(0) as usize, + timeout_ms: timeoutMs.unwrap_or(0).max(0) as u64, + max_stack: maxStack.unwrap_or(0).max(0) as usize, isolated, - ) - .map_err(to_php_err)?; + max_queued_message_bytes, + cache_entries: transpile_cache_max_entries, + cache_bytes: transpile_cache_max_bytes, + })?; Ok(QuickJS { engine }) } /// Register a PHP callable under a flat, dotted capability name, optionally /// with a TypeScript signature used for `.d.ts` generation. The name /// becomes callable from JS as `php.(...)`. - pub fn register( - &mut self, - name: String, - callable: &Zval, - types: Option, - ) -> PhpResult<()> { + pub fn register(&self, name: String, callable: &Zval, types: Option) -> PhpResult<()> { + self.engine.ensure_open()?; self.engine .state .register(name, callable, types) - .map_err(PhpException::default) + .map_err(PhpException::from_message) } - /// Evaluate JS source and marshal the result back to a PHP value. The - /// `php.*` facade is installed fresh from the current manifest first. - pub fn eval(&self, code: String) -> PhpResult { - // TypeScript fast path: transpile to JS (types erased, esnext) before - // QuickJS ever sees the source. Transpile/syntax errors surface here, - // located at their original TS line/column. - let module = self.engine.transpile.get_or_transpile(&code).map_err(|e| { - let stack = if e.line > 0 { - format!(" at guest.ts:{}:{}", e.line, e.col) - } else { - String::new() - }; - exceptions::eval_exception( - "SyntaxError".to_owned(), - e.message, - "guest.ts", - e.line, - stack, - ) - })?; + /// Evaluate source and marshal its result back to PHP. TypeScript is + /// transpiled by default; false executes JavaScript directly. + #[php(defaults(typescript = true))] + pub fn eval(&self, code: String, typescript: bool) -> PhpResult { + self.engine.eval(code, typescript) + } - let state = self.engine.state.clone(); - self.engine.arm_deadline(); - let outcome = self.engine.eval_in(|ctx| { - let map = module.map_json.clone(); - let eval_err = |e| self.classify_js_error(ctx, e, map.as_deref(), &module.module_id); - bridge::install(ctx, state.clone()).map_err(&eval_err)?; - // Name the module so stack frames are attributable and remappable. - let mut opts = rquickjs::context::EvalOptions::default(); - opts.filename = Some(module.module_id.clone()); - let value: rquickjs::Value = ctx - .eval_with_options(module.js.as_bytes(), opts) - .map_err(&eval_err)?; - let middle = js_to_middle(ctx, value, &state).map_err(&eval_err)?; - middle_to_zval(&middle, &state).map_err(PhpException::default) - }); - self.engine.disarm_deadline(); - match outcome { - Ok(r) => r, - Err(e) => Err(to_php_err(e)), + /// Stop this runtime and release its native worker. Further entries fail. + pub fn close(&self) -> PhpResult<()> { + self.engine.close() + } + + /// Whether Promise jobs are ready. This does not include pending host I/O. + pub fn hasPendingJobs(&self) -> PhpResult { + self.require_shared_jobs()?; + Ok(self.engine.has_jobs()?.bool().unwrap_or(false)) + } + + /// Execute at most maxJobs ready jobs without waiting for host I/O. + #[php(defaults(maxJobs = 100))] + pub fn executePendingJobs(&self, maxJobs: i64) -> PhpResult { + self.require_shared_jobs()?; + if maxJobs <= 0 { + return Err(PhpException::from_message( + "maxJobs must be greater than zero".to_owned(), + )); } + Ok(self.engine.jobs(maxJobs)?.long().unwrap_or(0)) + } + + /// Drain messages emitted with `quickjs.postMessage`. Safe to call after + /// an awaiting driver yields; no JS entry or Promise job is executed. + pub fn drainMessages(&self) -> PhpResult { + let messages = marshal::MiddleValue::Array(self.engine.drain_messages()?); + middle_to_zval(&messages, &self.engine.state).map_err(PhpException::from_message) + } + + /// Enable automatic JS jobs and await the next non-empty message batch. + /// One consumer per non-isolated runtime; yields through Revolt when loaded. + pub fn awaitMessages(&self) -> PhpResult { + let messages = marshal::MiddleValue::Array(self.engine.await_messages()?); + middle_to_zval(&messages, &self.engine.state).map_err(PhpException::from_message) } /// Return the registration manifest as an array of `['name'=>..., 'types'=>...]`. @@ -134,7 +160,7 @@ impl QuickJS { }) .collect(), ); - marshal::middle_to_zval(&mv, state).map_err(PhpException::default) + marshal::middle_to_zval(&mv, state).map_err(PhpException::from_message) } /// Generate a TypeScript `.d.ts` declaration for the `php` global from the @@ -147,8 +173,9 @@ impl QuickJS { /// Grant JS access to a live PHP value (e.g. a database connection) as an /// opaque integer handle. The value is kept host-side; JS only ever sees /// the id and must pass it back to a capability to use it. - pub fn grant(&self, resource: &Zval) -> i64 { - self.engine.state.handles.grant(resource) + pub fn grant(&self, resource: &Zval) -> PhpResult { + self.engine.ensure_open()?; + Ok(self.engine.state.handles.grant(resource)) } /// Resolve a handle previously returned by `grant()` back to its live PHP @@ -158,7 +185,7 @@ impl QuickJS { .state .handles .resolve(handle) - .ok_or_else(|| PhpException::default(format!("unknown handle: {handle}"))) + .ok_or_else(|| PhpException::from_message(format!("unknown handle: {handle}"))) } /// Revoke a handle, releasing the host-side reference. Returns whether the @@ -171,70 +198,26 @@ impl QuickJS { /// leg of the marshaling pipeline (PHP -> middle -> JS -> middle -> PHP). pub fn roundtrip(&self, value: &Zval) -> PhpResult { let state = self.engine.state.clone(); - let middle = zval_to_middle(value, &state).map_err(PhpException::default)?; - let outcome = self.engine.eval_in(|ctx| { - // Runtime support must exist for any function reconstruction. - bridge::install(ctx, state.clone()) - .map_err(|e| PhpException::default(error::js_error_message(ctx, e)))?; - let js = middle_to_js(ctx, &middle, &state).map_err(to_php_err)?; - let back = js_to_middle(ctx, js, &state).map_err(to_php_err)?; - middle_to_zval(&back, &state).map_err(PhpException::default) - }); - match outcome { - Ok(r) => r, - Err(e) => Err(to_php_err(e)), - } + let middle = zval_to_middle(value, &state).map_err(PhpException::from_message)?; + self.engine.roundtrip(middle) } } impl QuickJS { - /// Map a JS-side failure to the most specific PHP exception class: timeout - /// (deadline tripped), memory (heap limit), else a generic eval error. For - /// the eval case the JS stack is remapped to TypeScript coordinates via the - /// module's source map and surfaced in the message. - fn classify_js_error( - &self, - ctx: &rquickjs::Ctx<'_>, - err: rquickjs::Error, - map_json: Option<&str>, - module_id: &str, - ) -> PhpException { - let parts = error::js_error_parts(ctx, err); - let message = parts.display_message(); - if self.engine.timed_out() { - return PhpException::from_class::(message); - } - if message.to_lowercase().contains("out of memory") { - return PhpException::from_class::(message); + fn require_shared_jobs(&self) -> PhpResult<()> { + if self.engine.isolated() { + return Err(PhpException::from_message( + "Promise jobs require shared mode (isolated: false)".to_owned(), + )); } - // Remap the guest stack to TypeScript coordinates (guest frames only), - // and surface it as a structured, JS-error-like exception. - let remapped = parts - .stack - .as_deref() - .zip(map_json) - .and_then(|(stack, map)| error::remap_stack(stack, map, module_id)); - let (line, _) = remapped - .as_deref() - .and_then(|s| error::top_frame_location(s, module_id)) - .unwrap_or((0, 0)); - exceptions::eval_exception( - parts.name, - message, - module_id, - line, - remapped.unwrap_or_default(), - ) + Ok(()) } } -fn to_php_err(e: E) -> PhpException { - PhpException::default(e.to_string()) -} - #[php_module] pub fn module(module: ModuleBuilder) -> ModuleBuilder { module + .version(env!("CARGO_PKG_VERSION")) // Exceptions first so subclasses can resolve their parent class entry. .class::() .class::() diff --git a/src/manifest.rs b/src/manifest.rs index 037891f..5251155 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -43,10 +43,14 @@ fn build_tree(entries: &[ManifestEntry]) -> Node { root } -/// Generate a `.d.ts` declaration for the `php` global from the manifest. +/// Generate `.d.ts` declarations for the native message API and registered PHP capabilities. pub fn to_dts(entries: &[ManifestEntry]) -> String { let tree = build_tree(entries); let mut out = String::from("// Generated by php-quickjs. Do not edit by hand.\n"); + out.push_str("type QuickJSMessage = null | undefined | boolean | number | string | Uint8Array | QuickJSMessage[] | { [key: string]: QuickJSMessage };\n"); + out.push_str( + "declare const quickjs: Readonly<{ postMessage(value: QuickJSMessage): void }>;\n", + ); out.push_str("declare const php: "); write_node(&tree, 0, &mut out); out.push_str(";\n"); @@ -138,6 +142,7 @@ mod tests { ]; let dts = to_dts(&entries); assert!(dts.contains("db: {")); + assert!(dts.contains("declare const quickjs:")); assert!(dts.contains("query(sql: string): unknown[];")); assert!(dts.contains("execute(...args: any[]): any;")); assert!(dts.contains("info(msg: string): void;")); diff --git a/src/marshal.rs b/src/marshal.rs index beaf7bb..285645a 100644 --- a/src/marshal.rs +++ b/src/marshal.rs @@ -1,30 +1,12 @@ -//! Value marshaling between three worlds via a neutral [`MiddleValue`]: -//! -//! ```text -//! JS Value <-> MiddleValue <-> PHP Zval -//! | -//! msgpack bytes (the `__host` wire format) -//! ``` -//! -//! `MiddleValue` (de)serializes to **native** msgpack types (nil/bool/int/ -//! float/str/bin/array/map) — not serde's tagged-enum form — so a JS-side -//! msgpack codec interoperates with it byte-for-byte. +//! Value marshaling between JS values and PHP zvals through [`MiddleValue`]. use crate::bridge::BridgeState; use crate::callback::JsCallback; use ext_php_rs::convert::IntoZval; -use ext_php_rs::types::{ArrayKey, ZendClassObject, ZendHashTable, Zval}; +use ext_php_rs::types::{ArrayKey, ZendClassObject, ZendHashTable, ZendStr, Zval}; use rquickjs::{Array, Ctx, Function, Object, TypedArray, Value}; -use serde::de::{Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; -use serde::ser::{Serialize, SerializeMap, SerializeSeq, Serializer}; -use std::fmt; - -/// Reserved msgpack-map keys tagging a function reference across the wire. -const JSFN_TAG: &str = "$__jsfn"; -const PHPFN_TAG: &str = "$__phpfn"; - -/// The neutral, self-describing value that bridges JS, PHP and the wire. -#[derive(Debug, Clone, PartialEq)] +/// The neutral value that bridges JS and PHP. +#[derive(Debug, Clone)] pub enum MiddleValue { Null, Bool(bool), @@ -41,18 +23,6 @@ pub enum MiddleValue { JsFn(u64), } -impl MiddleValue { - /// Encode to a msgpack byte payload (the `__host` wire form). - pub fn to_msgpack(&self) -> Result, rmp_serde::encode::Error> { - rmp_serde::to_vec(self) - } - - /// Decode a msgpack byte payload. - pub fn from_msgpack(bytes: &[u8]) -> Result { - rmp_serde::from_slice(bytes) - } -} - /// Map an `f64` to an int when it is integral and fits an `i64`, else keep it /// a float. QuickJS already stores small integral numbers as int32, so this /// only ever promotes the larger integral doubles that JS cannot tag as int. @@ -65,247 +35,216 @@ fn int_or_float(f: f64) -> MiddleValue { } // --------------------------------------------------------------------------- -// native-msgpack serde +// JS <-> MiddleValue // --------------------------------------------------------------------------- -impl Serialize for MiddleValue { - fn serialize(&self, s: S) -> Result { - match self { - MiddleValue::Null => s.serialize_unit(), - MiddleValue::Bool(b) => s.serialize_bool(*b), - MiddleValue::Int(i) => s.serialize_i64(*i), - MiddleValue::Float(f) => s.serialize_f64(*f), - MiddleValue::Str(v) => s.serialize_str(v), - MiddleValue::Bytes(b) => s.serialize_bytes(b), - MiddleValue::Array(items) => { - let mut seq = s.serialize_seq(Some(items.len()))?; - for it in items { - seq.serialize_element(it)?; - } - seq.end() - } - MiddleValue::Map(entries) => { - let mut map = s.serialize_map(Some(entries.len()))?; - for (k, v) in entries { - map.serialize_entry(k, v)?; - } - map.end() - } - // Function refs travel as single-entry tagged maps. - MiddleValue::PhpFn(id) => { - let mut map = s.serialize_map(Some(1))?; - map.serialize_entry(PHPFN_TAG, &(*id as i64))?; - map.end() - } - MiddleValue::JsFn(id) => { - let mut map = s.serialize_map(Some(1))?; - map.serialize_entry(JSFN_TAG, &(*id as i64))?; - map.end() - } +pub const MAX_VALUE_DEPTH: usize = 64; +pub const VALUE_OVERHEAD: usize = 64; + +/// Messages have a byte budget; the generic API remains unbounded in bytes. +/// Both paths bound recursion before visiting a value. +#[derive(Default)] +struct ConversionBudget { + limit: Option, + used: usize, +} +impl ConversionBudget { + fn with_limit(limit: usize) -> Self { + Self { + limit: Some(limit), + used: 0, } } -} - -impl<'de> Deserialize<'de> for MiddleValue { - fn deserialize>(d: D) -> Result { - struct V; - impl<'de> Visitor<'de> for V { - type Value = MiddleValue; - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("a msgpack value") - } - fn visit_unit(self) -> Result { - Ok(MiddleValue::Null) - } - fn visit_none(self) -> Result { - Ok(MiddleValue::Null) - } - fn visit_bool(self, v: bool) -> Result { - Ok(MiddleValue::Bool(v)) - } - fn visit_i64(self, v: i64) -> Result { - Ok(MiddleValue::Int(v)) - } - fn visit_u64(self, v: u64) -> Result { - Ok(i64::try_from(v).map_or(MiddleValue::Float(v as f64), MiddleValue::Int)) - } - fn visit_f64(self, v: f64) -> Result { - Ok(MiddleValue::Float(v)) - } - fn visit_str(self, v: &str) -> Result { - Ok(MiddleValue::Str(v.to_owned())) - } - fn visit_string(self, v: String) -> Result { - Ok(MiddleValue::Str(v)) - } - fn visit_bytes(self, v: &[u8]) -> Result { - Ok(MiddleValue::Bytes(v.to_owned())) - } - fn visit_byte_buf(self, v: Vec) -> Result { - Ok(MiddleValue::Bytes(v)) - } - fn visit_seq>(self, mut seq: A) -> Result { - let mut out = Vec::new(); - while let Some(it) = seq.next_element()? { - out.push(it); - } - Ok(MiddleValue::Array(out)) - } - fn visit_map>(self, mut map: A) -> Result { - let mut out = Vec::new(); - while let Some((k, v)) = map.next_entry::()? { - out.push((k.0, v)); - } - // A single-entry map keyed by a reserved tag is a function ref. - if out.len() == 1 { - if let (key, MiddleValue::Int(id)) = &out[0] { - if key == JSFN_TAG { - return Ok(MiddleValue::JsFn(*id as u64)); - } - if key == PHPFN_TAG { - return Ok(MiddleValue::PhpFn(*id as u64)); - } - } - } - Ok(MiddleValue::Map(out)) - } + fn remaining(&self) -> Option { + self.limit.map(|limit| limit - self.used) + } + fn charge(&mut self, bytes: usize) -> Result<(), &'static str> { + let used = self + .used + .checked_add(bytes) + .ok_or("bridge value exceeds size limit")?; + if self.limit.is_some_and(|limit| used > limit) { + return Err("bridge value exceeds size limit"); } - d.deserialize_any(V) + self.used = used; + Ok(()) + } + fn node(&mut self, depth: usize) -> Result<(), &'static str> { + if depth > MAX_VALUE_DEPTH { + return Err("bridge value exceeds maximum depth (64)"); + } + self.charge(VALUE_OVERHEAD) } } -/// A map key coerced to a string (msgpack maps may key by non-string scalars). -struct MapKey(String); -impl<'de> Deserialize<'de> for MapKey { - fn deserialize>(d: D) -> Result { - struct K; - impl<'de> Visitor<'de> for K { - type Value = MapKey; - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("a map key") - } - fn visit_str(self, v: &str) -> Result { - Ok(MapKey(v.to_owned())) - } - fn visit_string(self, v: String) -> Result { - Ok(MapKey(v)) - } - fn visit_i64(self, v: i64) -> Result { - Ok(MapKey(v.to_string())) - } - fn visit_u64(self, v: u64) -> Result { - Ok(MapKey(v.to_string())) +/// Failed conversion never hands the registered functions to PHP. Defer their +/// deletion just like dropped wrappers, without executing JS during unwinding. +pub fn js_to_middle<'js>(ctx: &Ctx<'js>, value: Value<'js>) -> rquickjs::Result { + let mut conversion = JsConversion { + ctx, + budget: ConversionBudget::default(), + functions: true, + registered: Vec::new(), + }; + let result = conversion.convert(value, 0); + if result.is_err() { + for id in conversion.registered { + if let Ok(functions) = ctx.globals().get::<_, Object>("__jsFunctions") { + let _ = functions.remove(id as f64); } } - d.deserialize_any(K) } + result } -// --------------------------------------------------------------------------- -// JS <-> MiddleValue -// --------------------------------------------------------------------------- - -/// Convert a JS value into the neutral representation. Functions are registered -/// in the JS-side registry and travel as a [`MiddleValue::JsFn`] ref. -pub fn js_to_middle<'js>( +/// Return the accounted size together with data, without traversing it twice. +pub fn js_to_data<'js>( ctx: &Ctx<'js>, value: Value<'js>, - _state: &BridgeState, -) -> rquickjs::Result { - if value.is_null() || value.is_undefined() { - return Ok(MiddleValue::Null); - } - if let Some(b) = value.as_bool() { - return Ok(MiddleValue::Bool(b)); - } - if value.is_int() { - return Ok(MiddleValue::Int(value.as_int().unwrap() as i64)); - } - if value.is_float() { - return Ok(int_or_float(value.as_float().unwrap())); - } - if let Some(s) = value.as_string() { - return Ok(MiddleValue::Str(s.to_string()?)); - } - if value.is_function() { - // Register the function JS-side; PHP receives an opaque id. - let register: Function = ctx.globals().get("__registerJsFn")?; - let id: f64 = register.call((value.clone(),))?; - return Ok(MiddleValue::JsFn(id as u64)); - } - // Uint8Array -> Bytes (checked before the generic object branch). - if value.is_object() { - if let Ok(ta) = TypedArray::::from_value(value.clone()) { - if let Some(bytes) = ta.as_bytes() { - return Ok(MiddleValue::Bytes(bytes.to_vec())); + max_bytes: usize, +) -> rquickjs::Result<(MiddleValue, usize)> { + let mut conversion = JsConversion { + ctx, + budget: ConversionBudget::with_limit(max_bytes), + functions: false, + registered: Vec::new(), + }; + let data = conversion.convert(value, 0)?; + Ok((data, conversion.budget.used)) +} + +struct JsConversion<'a, 'js> { + ctx: &'a Ctx<'js>, + budget: ConversionBudget, + functions: bool, + registered: Vec, +} +impl<'js> JsConversion<'_, 'js> { + fn convert(&mut self, value: Value<'js>, depth: usize) -> rquickjs::Result { + let ctx = self.ctx; + self.budget + .node(depth) + .map_err(|e| rquickjs::Exception::throw_type(ctx, e))?; + if value.is_null() || value.is_undefined() { + return Ok(MiddleValue::Null); + } + if let Some(b) = value.as_bool() { + return Ok(MiddleValue::Bool(b)); + } + if value.is_int() { + return Ok(MiddleValue::Int(value.as_int().unwrap() as i64)); + } + if value.is_float() { + return Ok(int_or_float(value.as_float().unwrap())); + } + if let Some(s) = value.as_string() { + let text = s.to_string()?; + self.budget + .charge(text.len()) + .map_err(|e| rquickjs::Exception::throw_type(ctx, e))?; + return Ok(MiddleValue::Str(text)); + } + if value.is_function() { + if !self.functions { + return Err(rquickjs::Exception::throw_type( + ctx, + "direct messages cannot contain functions", + )); } + // Register the function JS-side; PHP receives an opaque id. + let register: Function = ctx.globals().get("__registerJsFn")?; + let id: f64 = register.call((value.clone(),))?; + self.registered.push(id as u64); + return Ok(MiddleValue::JsFn(id as u64)); } - } - if value.is_array() { - let arr = value.into_array().unwrap(); - let mut out = Vec::with_capacity(arr.len()); - for i in 0..arr.len() { - out.push(js_to_middle(ctx, arr.get(i)?, _state)?); + // Uint8Array -> Bytes (checked before the generic object branch). + if value.is_object() { + if let Ok(ta) = TypedArray::::from_value(value.clone()) { + // SAFETY: copy the bytes before executing any JavaScript; budget accounting + // only updates Rust state and throws after the slice is no longer used. + if let Some(bytes) = unsafe { ta.as_bytes() } { + self.budget + .charge(bytes.len()) + .map_err(|e| rquickjs::Exception::throw_type(ctx, e))?; + return Ok(MiddleValue::Bytes(bytes.to_vec())); + } + } } - return Ok(MiddleValue::Array(out)); - } - if value.is_object() { - let obj = value.into_object().unwrap(); - let mut out = Vec::new(); - for entry in obj.props::() { - let (k, v) = entry?; - out.push((k, js_to_middle(ctx, v, _state)?)); - } - return Ok(MiddleValue::Map(out)); + if value.is_array() { + let arr = value.into_array().unwrap(); + if self + .budget + .remaining() + .is_some_and(|remaining| arr.len() > remaining / VALUE_OVERHEAD) + { + return Err(rquickjs::Exception::throw_type( + ctx, + "bridge array exceeds size limit", + )); + } + let mut out = Vec::with_capacity(arr.len()); + for i in 0..arr.len() { + out.push(self.convert(arr.get(i)?, depth + 1)?); + } + return Ok(MiddleValue::Array(out)); + } + if value.is_object() { + let obj = value.into_object().unwrap(); + let mut out = Vec::new(); + for entry in obj.props::() { + let (k, v) = entry?; + self.budget + .charge(k.len()) + .map_err(|e| rquickjs::Exception::throw_type(ctx, e))?; + out.push((k, self.convert(v, depth + 1)?)); + } + return Ok(MiddleValue::Map(out)); + } + Err(rquickjs::Exception::throw_type( + ctx, + "unsupported JS value type", + )) } - Err(rquickjs::Exception::throw_type( - ctx, - "unsupported JS value type", - )) } -/// Convert the neutral representation into a JS value. -pub fn middle_to_js<'js>( - ctx: &Ctx<'js>, - value: &MiddleValue, - _state: &BridgeState, -) -> rquickjs::Result> { +/// Convert owned values into JS, transferring binary buffers without copying. +pub fn middle_into_js<'js>(ctx: &Ctx<'js>, value: MiddleValue) -> rquickjs::Result> { Ok(match value { MiddleValue::Null => Value::new_null(ctx.clone()), - MiddleValue::Bool(b) => Value::new_bool(ctx.clone(), *b), + MiddleValue::Bool(b) => Value::new_bool(ctx.clone(), b), MiddleValue::Int(i) => { - if let Ok(i32v) = i32::try_from(*i) { + if let Ok(i32v) = i32::try_from(i) { Value::new_int(ctx.clone(), i32v) } else { // Beyond i32: represent as a JS number (exact up to 2^53). - Value::new_float(ctx.clone(), *i as f64) + Value::new_float(ctx.clone(), i as f64) } } - MiddleValue::Float(f) => Value::new_float(ctx.clone(), *f), - MiddleValue::Str(s) => rquickjs::String::from_str(ctx.clone(), s)?.into_value(), - MiddleValue::Bytes(b) => TypedArray::new(ctx.clone(), b.clone())?.into_value(), + MiddleValue::Float(f) => Value::new_float(ctx.clone(), f), + MiddleValue::Str(s) => rquickjs::String::from_str(ctx.clone(), &s)?.into_value(), + MiddleValue::Bytes(b) => TypedArray::new(ctx.clone(), b)?.into_value(), MiddleValue::Array(items) => { let arr = Array::new(ctx.clone())?; - for (i, it) in items.iter().enumerate() { - arr.set(i, middle_to_js(ctx, it, _state)?)?; + for (i, it) in items.into_iter().enumerate() { + arr.set(i, middle_into_js(ctx, it)?)?; } arr.into_value() } MiddleValue::Map(entries) => { let obj = Object::new(ctx.clone())?; for (k, v) in entries { - obj.set(k.as_str(), middle_to_js(ctx, v, _state)?)?; + obj.set(k, middle_into_js(ctx, v)?)?; } obj.into_value() } // Reconstruct callables from the JS-side helpers. MiddleValue::JsFn(id) => { - let get: Function = ctx.globals().get("__getJsFn")?; - get.call((*id as f64,))? + let functions: Object = ctx.globals().get("__jsFunctions")?; + functions.get(id as f64)? } MiddleValue::PhpFn(id) => { let make: Function = ctx.globals().get("__makePhpFn")?; - make.call((*id as f64,))? + make.call((id as f64,))? } }) } @@ -314,70 +253,113 @@ pub fn middle_to_js<'js>( // PHP Zval <-> MiddleValue // --------------------------------------------------------------------------- -/// Convert a PHP value into the neutral representation. A `Js\Callback` becomes -/// a [`MiddleValue::JsFn`] ref; any other PHP callable is registered host-side -/// as a [`MiddleValue::PhpFn`]. +/// Registrations are committed only once the complete input is valid. pub fn zval_to_middle(zv: &Zval, state: &BridgeState) -> Result { - if zv.is_null() { - return Ok(MiddleValue::Null); - } - if zv.is_bool() { - return Ok(MiddleValue::Bool(zv.bool().unwrap_or(false))); - } - if zv.is_long() { - return Ok(MiddleValue::Int(zv.long().unwrap())); - } - if zv.is_double() { - return Ok(MiddleValue::Float(zv.double().unwrap())); - } - if zv.is_string() { - // PHP strings are byte strings. Preserve valid UTF-8 as a string; - // anything else (binary data) crosses as bytes -> JS Uint8Array. - let bytes = zv.zend_str().map(|zs| zs.as_bytes()).unwrap_or(&[]); - return Ok(match std::str::from_utf8(bytes) { - Ok(s) => MiddleValue::Str(s.to_owned()), - Err(_) => MiddleValue::Bytes(bytes.to_owned()), - }); - } - if zv.is_array() { - let ht = zv.array().unwrap(); - return hashtable_to_middle(ht, state); + let mut conversion = PhpConversion::new(state); + let value = conversion.convert(zv, 0)?; + conversion.registered.clear(); + Ok(value) +} + +pub fn arguments_to_middle( + args: &[&Zval], + state: &BridgeState, +) -> Result, String> { + let mut conversion = PhpConversion::new(state); + let result = args + .iter() + .map(|arg| conversion.convert(arg, 0)) + .collect::, _>>()?; + conversion.registered.clear(); + Ok(result) +} + +struct PhpConversion<'a> { + state: &'a BridgeState, + budget: ConversionBudget, + registered: Vec, +} +impl<'a> PhpConversion<'a> { + fn new(state: &'a BridgeState) -> Self { + Self { + state, + budget: ConversionBudget::default(), + registered: Vec::new(), + } } - // A returned Js\Callback maps back to its original JS function. - if zv.is_object() { + fn convert(&mut self, zv: &Zval, depth: usize) -> Result { + self.budget.node(depth)?; + if zv.is_null() { + return Ok(MiddleValue::Null); + } + if zv.is_bool() { + return Ok(MiddleValue::Bool(zv.bool().unwrap_or(false))); + } + if zv.is_long() { + return Ok(MiddleValue::Int(zv.long().unwrap())); + } + if zv.is_double() { + return Ok(MiddleValue::Float(zv.double().unwrap())); + } + if zv.is_string() { + let bytes = zv.zend_str().map(|zs| zs.as_bytes()).unwrap_or(&[]); + self.budget.charge(bytes.len())?; + return Ok(match std::str::from_utf8(bytes) { + Ok(s) => MiddleValue::Str(s.to_owned()), + Err(_) => MiddleValue::Bytes(bytes.to_owned()), + }); + } + if let Some(array) = zv.array() { + return self.array(array, depth); + } if let Some(cb) = zv.extract::<&ZendClassObject>() { + let owner = self.state.engine().ok_or("engine no longer available")?; + if !std::rc::Rc::ptr_eq(&owner, &cb.engine) { + return Err("JS callback belongs to a different QuickJS instance".to_owned()); + } + cb.check_realm()?; return Ok(MiddleValue::JsFn(cb.id)); } + if zv.is_callable() { + let id = self.state.register_php_fn(zv); + self.registered.push(id); + return Ok(MiddleValue::PhpFn(id)); + } + Err("unsupported PHP value type for marshaling".to_owned()) } - // Any other callable (closure, [obj, 'method'] is caught above as array) is - // registered host-side and handed to JS as a callable wrapper. - if zv.is_callable() { - return Ok(MiddleValue::PhpFn(state.register_php_fn(zv))); + fn array(&mut self, ht: &ZendHashTable, depth: usize) -> Result { + if self + .budget + .remaining() + .is_some_and(|remaining| ht.len() > remaining / VALUE_OVERHEAD) + { + return Err("bridge array exceeds size limit".to_owned()); + } + if ht.has_sequential_keys() { + let mut out = Vec::with_capacity(ht.len()); + for (_, value) in ht.iter() { + out.push(self.convert(value, depth + 1)?); + } + Ok(MiddleValue::Array(out)) + } else { + let mut out = Vec::with_capacity(ht.len()); + for (key, value) in ht.iter() { + let key = match key { + ArrayKey::Long(i) => i.to_string(), + ArrayKey::String(s) => s, + ArrayKey::Str(s) => s.to_owned(), + ArrayKey::ZendString(s) => s.try_into().unwrap_or_default(), + }; + self.budget.charge(key.len())?; + out.push((key, self.convert(value, depth + 1)?)); + } + Ok(MiddleValue::Map(out)) + } } - Err("unsupported PHP value type for marshaling".to_owned()) } - -/// A PHP array becomes an [`MiddleValue::Array`] when its keys are the -/// sequential `0..n`, otherwise an insertion-ordered [`MiddleValue::Map`]. -fn hashtable_to_middle(ht: &ZendHashTable, state: &BridgeState) -> Result { - if ht.has_sequential_keys() { - let mut out = Vec::with_capacity(ht.len()); - for (_, v) in ht.iter() { - out.push(zval_to_middle(v, state)?); - } - Ok(MiddleValue::Array(out)) - } else { - let mut out = Vec::with_capacity(ht.len()); - for (k, v) in ht.iter() { - let key = match k { - ArrayKey::Long(i) => i.to_string(), - ArrayKey::String(s) => s, - ArrayKey::Str(s) => s.to_owned(), - ArrayKey::ZendString(s) => s.try_into().unwrap_or_default(), - }; - out.push((key, zval_to_middle(v, state)?)); - } - Ok(MiddleValue::Map(out)) +impl Drop for PhpConversion<'_> { + fn drop(&mut self) { + self.state.release_php_fns(&self.registered); } } @@ -392,7 +374,7 @@ pub fn middle_to_zval(value: &MiddleValue, state: &BridgeState) -> Result zv .set_string(s, false) .map_err(|e| format!("string conversion failed: {e}"))?, - MiddleValue::Bytes(b) => zv.set_binary(b.clone()), + MiddleValue::Bytes(b) => zv.set_zend_string(ZendStr::new(b, false)), MiddleValue::Array(items) => { let mut ht = ZendHashTable::new(); for it in items { @@ -428,54 +410,3 @@ pub fn middle_to_zval(value: &MiddleValue, state: &BridgeState) -> Result(v: T) -> Result { - v.into_zval(false).map_err(|e| e.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn roundtrip(v: MiddleValue) { - let bytes = v.to_msgpack().expect("encode"); - let back = MiddleValue::from_msgpack(&bytes).expect("decode"); - assert_eq!(v, back); - } - - #[test] - fn msgpack_scalars() { - roundtrip(MiddleValue::Null); - roundtrip(MiddleValue::Bool(true)); - roundtrip(MiddleValue::Int(-42)); - roundtrip(MiddleValue::Int(1 << 40)); - roundtrip(MiddleValue::Float(3.5)); - roundtrip(MiddleValue::Str("héllo".to_owned())); - roundtrip(MiddleValue::Bytes(vec![0, 1, 2, 255])); - } - - #[test] - fn msgpack_nested() { - roundtrip(MiddleValue::Array(vec![ - MiddleValue::Int(1), - MiddleValue::Str("two".into()), - MiddleValue::Bool(false), - ])); - roundtrip(MiddleValue::Map(vec![ - ("a".into(), MiddleValue::Int(1)), - ( - "nested".into(), - MiddleValue::Array(vec![MiddleValue::Null, MiddleValue::Float(2.5)]), - ), - ])); - } - - #[test] - fn bytes_encode_as_msgpack_bin() { - // msgpack bin8 marker is 0xc4; ensure bytes do not serialize as an array. - let bytes = MiddleValue::Bytes(vec![1, 2, 3]).to_msgpack().unwrap(); - assert_eq!(bytes[0], 0xc4); - } -} diff --git a/src/notification.c b/src/notification.c new file mode 100644 index 0000000..524f572 --- /dev/null +++ b/src/notification.c @@ -0,0 +1,32 @@ +#include +#include +#include
+#include
+#include +#include + +bool php_quickjs_is_unwinding(void) +{ + return EG(exception) && (zend_is_unwind_exit(EG(exception)) || zend_is_graceful_exit(EG(exception))); +} +#ifdef __linux__ +#include +int php_quickjs_notification_fd(void) { + return eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); +} +#endif + +/* PHP owns a duplicate, so closing its stream cannot close the Rust socket. + * Using the streams API also supports FPM/CGI, unlike php://fd. */ +bool php_quickjs_notification_stream(int fd, zval *result) +{ + int owned = fcntl(fd, F_DUPFD_CLOEXEC, 0); + if (owned < 0) return false; + php_stream *stream = php_stream_fopen_from_fd(owned, "r", NULL); + if (stream == NULL) { + close(owned); + return false; + } + php_stream_to_zval(stream, result); + return true; +} diff --git a/src/sandbox.rs b/src/sandbox.rs deleted file mode 100644 index a5238fa..0000000 --- a/src/sandbox.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! In-engine resource containment: memory limit, native stack size, and a -//! wall-clock deadline enforced through QuickJS's interrupt handler. -//! -//! These are *resource* guards (infinite loops, alloc bombs), not a defence -//! against QuickJS memory-corruption bugs — for hostile code, nest the whole -//! extension inside an outer microVM/gVisor boundary. - -use rquickjs::Runtime; -use std::cell::Cell; -use std::rc::Rc; -use std::time::Instant; - -/// Apply the static limits. A value of `0` means "leave unbounded" (QuickJS -/// treats a literal 0 limit as zero bytes, so we simply skip the call). -pub fn apply_limits(rt: &Runtime, memory_limit: usize, max_stack: usize) { - if memory_limit > 0 { - rt.set_memory_limit(memory_limit); - } - if max_stack > 0 { - rt.set_max_stack_size(max_stack); - } -} - -/// Install the wall-clock interrupt handler. It fires between QuickJS -/// operations; returning `true` aborts execution. When it trips on the -/// deadline it records that fact so the eval boundary can raise a timeout -/// (rather than a generic) exception. -pub fn install_interrupt( - rt: &Runtime, - deadline: Rc>>, - timed_out: Rc>, -) { - rt.set_interrupt_handler(Some(Box::new(move || match deadline.get() { - Some(dl) if Instant::now() >= dl => { - timed_out.set(true); - true - } - _ => false, - }))); -} diff --git a/src/transpile.rs b/src/transpile.rs index fa1578f..e11a54b 100644 --- a/src/transpile.rs +++ b/src/transpile.rs @@ -130,22 +130,65 @@ impl CompilerInterface for TsCompiler { // content-addressed cache // --------------------------------------------------------------------------- +#[cfg(test)] +const CACHE_BYTE_BUDGET: usize = 32 * 1024 * 1024; + struct CachedModule { source: String, transpiled: Transpiled, } +impl CachedModule { + fn byte_len(&self) -> usize { + self.source.len() + + self.transpiled.js.len() + + self.transpiled.map_json.as_ref().map_or(0, |map| map.len()) + } +} + +struct CacheState { + entries: LruCache, + bytes: usize, +} + +impl CacheState { + fn insert(&mut self, key: u64, module: CachedModule, budget: usize) { + let bytes = module.byte_len(); + // Large guests still execute, without evicting useful cached modules. + if bytes > budget { + return; + } + if let Some(previous) = self.entries.pop(&key) { + self.bytes -= previous.byte_len(); + } + while self.bytes + bytes > budget || self.entries.len() == self.entries.cap().get() { + if let Some((_, oldest)) = self.entries.pop_lru() { + self.bytes -= oldest.byte_len(); + } else { + break; + } + } + self.entries.put(key, module); + self.bytes += bytes; + } +} + /// A small LRU mapping `hash(source)` -> transpiled output. Single-threaded /// (PHP NTS), so a `RefCell` is sufficient. pub struct TranspileCache { - inner: RefCell>, + inner: RefCell, + byte_budget: usize, } impl TranspileCache { - pub fn new(capacity: usize) -> Self { + pub fn new(capacity: usize, byte_budget: usize) -> Self { let cap = NonZeroUsize::new(capacity.max(1)).unwrap(); TranspileCache { - inner: RefCell::new(LruCache::new(cap)), + byte_budget: if capacity == 0 { 0 } else { byte_budget }, + inner: RefCell::new(CacheState { + entries: LruCache::new(cap), + bytes: 0, + }), } } @@ -154,7 +197,7 @@ impl TranspileCache { /// to rule out a hash collision returning the wrong JS. pub fn get_or_transpile(&self, source: &str) -> Result { let key = hash(source); - if let Some(hit) = self.inner.borrow_mut().get(&key) { + if let Some(hit) = self.inner.borrow_mut().entries.get(&key) { if hit.source == source { return Ok(hit.transpiled.clone()); } @@ -168,13 +211,16 @@ impl TranspileCache { js: Rc::from(js.as_str()), map_json: map_json.map(|m| Rc::from(m.as_str())), }; - self.inner.borrow_mut().put( - key, - CachedModule { - source: source.to_owned(), - transpiled: transpiled.clone(), - }, - ); + if self.byte_budget > 0 { + self.inner.borrow_mut().insert( + key, + CachedModule { + source: source.to_owned(), + transpiled: transpiled.clone(), + }, + self.byte_budget, + ); + } Ok(transpiled) } } @@ -218,10 +264,137 @@ mod tests { #[test] fn cache_hits_return_same_output() { - let cache = TranspileCache::new(8); + let cache = TranspileCache::new(8, CACHE_BYTE_BUDGET); let a = cache.get_or_transpile("const a: number = 1; a;").unwrap(); let b = cache.get_or_transpile("const a: number = 1; a;").unwrap(); assert_eq!(a.js, b.js); + assert!(Rc::ptr_eq(&a.js, &b.js)); + let state = cache.inner.borrow(); + assert_eq!(state.entries.len(), 1); + assert_eq!( + state.bytes, + state + .entries + .peek(&hash("const a: number = 1; a;")) + .unwrap() + .byte_len() + ); assert_eq!(a.module_id, b.module_id); } + + #[test] + fn configurable_limits_can_disable_or_bound_cache() { + let source = "const answer: number = 42; answer;"; + for (entries, bytes) in [(0, CACHE_BYTE_BUDGET), (8, 0), (8, 1)] { + let cache = TranspileCache::new(entries, bytes); + let first = cache.get_or_transpile(source).unwrap(); + let second = cache.get_or_transpile(source).unwrap(); + assert_eq!(first.js, second.js); + assert!(!Rc::ptr_eq(&first.js, &second.js)); + assert!(cache.inner.borrow().entries.is_empty()); + assert_eq!(cache.inner.borrow().bytes, 0); + } + let cache = TranspileCache::new(1, CACHE_BYTE_BUDGET); + let first = cache.get_or_transpile(source).unwrap(); + cache + .get_or_transpile("const other: number = 1; other;") + .unwrap(); + let repeated = cache.get_or_transpile(source).unwrap(); + assert!(!Rc::ptr_eq(&first.js, &repeated.js)); + assert_eq!(cache.inner.borrow().entries.len(), 1); + } + + fn module(source: &str, js: &str, map: Option<&str>) -> CachedModule { + CachedModule { + source: source.to_owned(), + transpiled: Transpiled { + module_id: "guest.ts".to_owned(), + js: Rc::from(js), + map_json: map.map(Rc::from), + }, + } + } + + fn state(capacity: usize) -> CacheState { + CacheState { + entries: LruCache::new(NonZeroUsize::new(capacity).unwrap()), + bytes: 0, + } + } + + #[test] + fn cache_accounts_source_js_and_map_bytes() { + let mut cache = state(8); + cache.insert(1, module("é", "abc", Some("map")), 32); + assert_eq!(cache.bytes, 8); + cache.insert(2, module("src", "js", None), 32); + assert_eq!(cache.bytes, 13); + } + + #[test] + fn cache_evicts_lru_for_entry_and_byte_limits() { + let mut cache = state(2); + cache.insert(1, module("one", "js", None), 12); + cache.insert(2, module("two", "js", None), 12); + cache.entries.get(&1); + cache.insert(3, module("three", "js", None), 12); + assert!(cache.entries.peek(&2).is_none()); + assert!(cache.entries.peek(&1).is_some()); + assert_eq!(cache.bytes, 12); + + cache.insert(4, module("four", "js", None), 12); + assert!(cache.entries.peek(&1).is_none()); + assert!(cache.entries.peek(&3).is_none()); + assert_eq!(cache.entries.len(), 1); + assert_eq!(cache.bytes, 6); + } + + #[test] + fn cache_entry_limit_evicts_even_with_byte_budget_remaining() { + let mut cache = state(2); + cache.insert(1, module("one", "js", None), 100); + cache.insert(2, module("two", "js", None), 100); + cache.entries.get(&1); + cache.insert(3, module("three", "js", None), 100); + assert!(cache.entries.peek(&2).is_none()); + assert!(cache.entries.peek(&1).is_some()); + assert!(cache.entries.peek(&3).is_some()); + assert_eq!(cache.bytes, 12); + } + + #[test] + fn cache_replacements_and_oversized_entries_preserve_accounting() { + let mut cache = state(8); + cache.insert(1, module("one", "js", Some("map")), 10); + cache.insert(1, module("two", "js", None), 10); + assert_eq!(cache.bytes, 5); + assert_eq!(cache.entries.len(), 1); + cache.insert(1, module("too large", "js", None), 10); + assert_eq!(cache.bytes, 5); + assert_eq!(cache.entries.peek(&1).unwrap().source, "two"); + cache.insert(2, module("oversized", "js", None), 10); + assert_eq!(cache.entries.len(), 1); + cache.insert(2, module("small", "12345", None), 10); + assert_eq!(cache.bytes, 10); + assert!(cache.entries.peek(&1).is_none()); + } + + #[test] + fn hash_collision_transpiles_and_replaces_wrong_entry() { + let cache = TranspileCache::new(8, CACHE_BYTE_BUDGET); + let source = "const correct: number = 42; correct;"; + cache.inner.borrow_mut().insert( + hash(source), + module("different source", "wrong JS", Some("wrong map")), + CACHE_BYTE_BUDGET, + ); + let output = cache.get_or_transpile(source).unwrap(); + assert!(output.js.contains("42")); + assert!(!output.js.contains("wrong JS")); + let state = cache.inner.borrow(); + assert_eq!(state.entries.len(), 1); + let entry = state.entries.peek(&hash(source)).unwrap(); + assert_eq!(entry.source, source); + assert_eq!(state.bytes, entry.byte_len()); + } } diff --git a/src/worker.rs b/src/worker.rs new file mode 100644 index 0000000..be69716 --- /dev/null +++ b/src/worker.rs @@ -0,0 +1,1256 @@ +//! Runtime ownership and RPC. Driver and Session retain only owned wire values; +//! PHP exception conversion is called by the PHP-thread facade. +use crate::{ + bridge::{build_facade, MessageQueue, RUNTIME_JS}, + error::{self, HostError, JsErrorParts}, + marshal::{js_to_data, js_to_middle, middle_into_js, MiddleValue as Data}, + transpile::{TranspileCache, Transpiled}, +}; +use ext_php_rs::prelude::PhpException; +use rquickjs::{function::This, Context, Ctx, Exception, Function, Promise, Runtime, Value}; +use std::{ + cell::{Cell, RefCell}, + collections::{HashMap, HashSet, VecDeque}, + fs::File, + io::Write, + rc::Rc, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, Arc, Mutex, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +#[cfg(target_os = "linux")] +fn notification_pair() -> std::io::Result<(File, File)> { + use std::os::fd::FromRawFd; + unsafe extern "C" { + fn php_quickjs_notification_fd() -> i32; + } + let fd = unsafe { php_quickjs_notification_fd() }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + // The PHP thread owns the reader until after joining the writer's thread. + let reader = unsafe { File::from_raw_fd(fd) }; + let writer = reader.try_clone()?; + Ok((reader, writer)) +} +#[cfg(not(target_os = "linux"))] +fn notification_pair() -> std::io::Result<(File, File)> { + use std::os::{fd::OwnedFd, unix::net::UnixStream}; + let (reader, writer) = UnixStream::pair()?; + reader.set_nonblocking(true)?; + writer.set_nonblocking(true)?; + Ok(( + File::from(OwnedFd::from(reader)), + File::from(OwnedFd::from(writer)), + )) +} + +pub struct Config { + pub memory_limit: usize, + pub timeout_ms: u64, + pub max_stack: usize, + pub isolated: bool, + pub max_queued_message_bytes: usize, + pub cache_entries: usize, + pub cache_bytes: usize, +} +// Each direction writes different counters. Keep them off one cache line. +#[derive(Default)] +#[repr(align(128))] +struct Signal(T); +impl std::ops::Deref for Signal { + type Target = T; + fn deref(&self) -> &T { + &self.0 + } +} +#[derive(Default)] +struct Signals { + stop: Signal, + notified: Signal, + pump: Signal, + posts: Signal, +} +pub enum Operation { + Eval { + code: String, + typescript: bool, + }, + Call { + id: u64, + args: Vec, + posted: bool, + }, + Capacity, + Jobs(i64), + HasJobs, + Roundtrip(Data), +} +pub struct Request { + pub id: u64, + pub op: Operation, + pub names: Option>, + pub deletions: Vec, + pub parent: Option, +} +pub enum HostTarget { + Named(String), + Function(u64), +} +pub enum Command { + Pump, + Request(Request), + HostReply { + token: u64, + result: Result, + }, + Abort { + id: u64, + message: String, + }, + Stop, +} +pub enum Event { + Messages, + AsyncError(WireError), + Fatal(WireError), + Done { + id: u64, + result: Result, + }, + Host { + owner: Option, + token: u64, + target: HostTarget, + args: Vec, + }, + NeedsLoop { + id: u64, + }, + Stopped(String), +} +pub enum WireError { + Internal(String), + Timeout, + Guest { + parts: JsErrorParts, + module: Option, + map: Option, + eval: bool, + }, + Syntax { + message: String, + line: u32, + col: u32, + }, +} +impl WireError { + pub fn into_php(self) -> PhpException { + match self { + Self::Internal(s) => PhpException::from_message(s), + Self::Timeout => { + PhpException::from_class::( + "JavaScript execution timed out".into(), + ) + } + Self::Syntax { message, line, col } => crate::exceptions::eval_exception( + "SyntaxError".into(), + message, + "guest.ts", + line, + format!(" at guest.ts:{line}:{col}"), + ), + Self::Guest { + parts, + module, + map, + eval, + } => { + if !eval { + return error::js_error_to_php(parts); + } + let message = parts.display_message(); + if matches!( + error::resource_error(&parts, false), + Some(error::ResourceError::Memory) + ) { + return PhpException::from_class::( + message, + ); + } + let module = module.unwrap_or_else(|| "guest.js".into()); + let stack = match (parts.stack.as_deref(), map.as_deref()) { + (Some(s), Some(m)) => error::remap_stack(s, m, &module), + (Some(s), None) => Some(s.into()), + _ => None, + }; + let line = stack + .as_deref() + .and_then(|s| error::top_frame_location(s, &module)) + .map_or(0, |x| x.0); + crate::exceptions::eval_exception( + parts.name, + message, + &module, + line, + stack.unwrap_or_default(), + ) + } + } + } +} +pub struct Port { + inner: std::mem::ManuallyDrop, + pid: u32, +} +pub struct PortInner { + tx: mpsc::SyncSender, + rx: mpsc::Receiver, + pub wake: File, + worker: RefCell>>, + signals: Arc, + messages: Arc>, +} +impl std::ops::Deref for Port { + type Target = PortInner; + fn deref(&self) -> &PortInner { + &self.inner + } +} +impl Port { + pub fn new(config: Config) -> Result { + let (wake, notify) = notification_pair().map_err(|e| WireError::Internal(e.to_string()))?; + let (tx, commands) = mpsc::sync_channel(256); + let (events, rx) = mpsc::channel(); + let signals = Arc::new(Signals::default()); + let worker_signals = signals.clone(); + let messages = Arc::new(Mutex::new(MessageQueue::new( + config.max_queued_message_bytes, + ))); + let queue = messages.clone(); + let (ready_tx, ready) = mpsc::sync_channel(1); + let worker = thread::Builder::new() + .name("php-quickjs".into()) + .stack_size( + config + .max_stack + .saturating_add(4 * 1024 * 1024) + .max(8 * 1024 * 1024), + ) + .spawn(move || { + let driver = Rc::new(Driver { + commands, + events, + notify, + signals: worker_signals, + messages: queue, + cache: TranspileCache::new(config.cache_entries, config.cache_bytes), + config, + token: Cell::new(0), + notification_pending: Cell::new(false), + }); + let outcome = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| driver.run(ready_tx))); + driver.signals.stop.store(true, Ordering::SeqCst); + if !matches!(outcome, Ok(Ok(()))) { + driver.emit(Event::Stopped( + "QuickJS worker terminated unexpectedly".into(), + )); + } + driver.notify(); + }) + .map_err(|e| WireError::Internal(e.to_string()))?; + let initialized = ready.recv().unwrap_or_else(|_| { + Err(WireError::Internal( + "QuickJS worker initialization failed".into(), + )) + }); + if let Err(error) = initialized { + signals.stop.store(true, Ordering::SeqCst); + let _ = tx.try_send(Command::Stop); + let _ = worker.join(); + return Err(error); + } + Ok(Self { + inner: std::mem::ManuallyDrop::new(PortInner { + tx, + rx, + wake, + worker: RefCell::new(Some(worker)), + signals, + messages, + }), + pid: std::process::id(), + }) + } + pub fn check_pid(&self) -> Result<(), WireError> { + if self.pid != std::process::id() { + return Err(WireError::Internal( + "QuickJS cannot be used after fork; create runtimes in the child".into(), + )); + } + Ok(()) + } + pub fn check(&self) -> Result<(), WireError> { + self.check_pid()?; + if self.signals.stop.load(Ordering::SeqCst) { + return Err(WireError::Internal("QuickJS worker is closed".into())); + } + Ok(()) + } + pub fn send(&self, command: Command) -> Result<(), WireError> { + self.check()?; + self.tx + .try_send(command) + .map_err(|_| WireError::Internal("QuickJS command queue is full or closed".into()))?; + Ok(()) + } + pub fn receive(&self) -> Result { + self.rx.try_recv() + } + pub fn acknowledge_notification(&self) { + // Clear before draining events: a concurrent enqueue either appears in + // this drain or writes another byte. Neither ordering loses a wakeup. + self.signals.notified.store(false, Ordering::SeqCst); + } + pub fn start_pump(&self) -> Result<(), WireError> { + self.check()?; + if !self.signals.pump.load(Ordering::SeqCst) + && !self.signals.pump.swap(true, Ordering::SeqCst) + { + self.send(Command::Pump)?; + } + Ok(()) + } + pub fn reserve_post(&self) -> bool { + self.signals + .posts + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| { + (n < 128).then_some(n + 1) + }) + .is_ok() + } + pub fn release_post(&self) { + self.signals.posts.fetch_sub(1, Ordering::Relaxed); + } + pub fn receive_blocking(&self) -> Result { + self.rx + .recv() + .map_err(|_| WireError::Internal("QuickJS worker disconnected".into())) + } + pub fn drain_messages(&self) -> Result, WireError> { + self.check_pid()?; + Ok(self + .messages + .lock() + .unwrap_or_else(|e| e.into_inner()) + .drain()) + } + pub fn close(&self) { + if self.pid != std::process::id() { + return; + } + self.signals.stop.store(true, Ordering::SeqCst); + let _ = self.tx.try_send(Command::Stop); + if let Some(worker) = self.worker.borrow_mut().take() { + let _ = worker.join(); + } + } +} +impl Drop for Port { + fn drop(&mut self) { + if self.pid == std::process::id() { + self.close(); + // SAFETY: the owner has joined its worker; inner is dropped exactly once. + unsafe { + std::mem::ManuallyDrop::drop(&mut self.inner); + } + } + // After fork, do not touch inherited channel/mutex state: its lock owner + // may no longer exist. Child process exit reclaims these inherited resources. + } +} +struct Driver { + commands: mpsc::Receiver, + events: mpsc::Sender, + notify: File, + signals: Arc, + messages: Arc>, + cache: TranspileCache, + config: Config, + token: Cell, + notification_pending: Cell, +} +impl Driver { + fn notify(&self) { + if !self.signals.notified.swap(true, Ordering::SeqCst) { + let _ = (&self.notify).write(&1u64.to_ne_bytes()); + } + } + fn emit(&self, event: Event) { + if matches!(&event, Event::Fatal(_)) && self.signals.stop.swap(true, Ordering::SeqCst) { + return; + } + let urgent = !self.signals.pump.load(Ordering::SeqCst) + || matches!( + &event, + Event::Host { .. } | Event::Stopped(_) | Event::AsyncError(_) | Event::Fatal(_) + ); + let _ = self.events.send(event); + self.notification_pending.set(true); + if urgent { + self.flush_notifications(); + } + } + fn flush_notifications(&self) { + if self.notification_pending.replace(false) { + self.notify(); + } + } + fn run(self: &Rc, ready: mpsc::SyncSender>) -> Result<(), String> { + if self.config.isolated { + let _ = ready.send(Ok(())); + loop { + let command = self.commands.recv().map_err(|e| e.to_string())?; + if self.signals.stop.load(Ordering::SeqCst) { + return Ok(()); + } + match command { + Command::Stop => return Ok(()), + Command::Request(request) => { + let id = request.id; + if !matches!( + &request.op, + Operation::Eval { .. } | Operation::Roundtrip(_) + ) { + self.emit(Event::Done { + id, + result: Err(WireError::Internal( + "JS callback belongs to an ended isolated eval".into(), + )), + }); + continue; + } + self.context(Some(request), None)?; + } + _ => {} + } + } + } else { + self.context(None, Some(ready)) + } + } + fn context( + self: &Rc, + first: Option, + ready: Option>>, + ) -> Result<(), String> { + let rt = Runtime::new().map_err(|e| e.to_string())?; + if self.config.memory_limit > 0 { + rt.set_memory_limit(self.config.memory_limit); + } + if self.config.max_stack > 0 { + rt.set_max_stack_size(self.config.max_stack); + } + let deadline = Rc::new(Cell::new(None::)); + let timed_out = Rc::new(Cell::new(false)); + let interrupted = self.signals.clone(); + let dl = deadline.clone(); + let expired = timed_out.clone(); + rt.set_interrupt_handler(Some(Box::new(move || { + if interrupted.stop.load(Ordering::SeqCst) { + return true; + } + if dl.get().is_some_and(|d| Instant::now() >= d) { + expired.set(true); + return true; + } + false + }))); + let context = Context::full(&rt).map_err(|e| e.to_string())?; + let root = first.as_ref().map(|r| r.id); + context.with(|ctx| { + let promise: rquickjs::Object = + ctx.globals().get("Promise").map_err(|e| e.to_string())?; + let prototype: rquickjs::Object = + promise.get("prototype").map_err(|e| e.to_string())?; + let then: Function = prototype.get("then").map_err(|e| e.to_string())?; + let session = Rc::new(Session { + driver: self.clone(), + pending: RefCell::new(HashMap::new()), + ready: Rc::new(RefCell::new(VecDeque::new())), + then: RefCell::new(Some(then)), + deferred_ready: RefCell::new(Vec::new()), + turn_deadline: Cell::new(None), + posted: RefCell::new(HashSet::new()), + capacity: RefCell::new(VecDeque::new()), + replies: RefCell::new(HashMap::new()), + entries: RefCell::new(Vec::new()), + deadline, + timed_out, + failed: RefCell::new(VecDeque::new()), + root, + root_finished: Cell::new(false), + facade: RefCell::new(None), + functions: RefCell::new(None), + js_depth: Cell::new(0), + reported_failure: Cell::new(false), + active_request: Cell::new(None), + }); + let _cleanup = SessionCleanup(session.clone()); + install(&ctx, session.clone()).map_err(|e| error::js_error_message(&ctx, e))?; + if let Some(ready) = ready { + let _ = ready.send(Ok(())); + } + if let Some(first) = first { + session.request(&ctx, first); + } + while session.step(&ctx, None) {} + let pending = std::mem::take(&mut *session.pending.borrow_mut()); + for (id, _) in pending { + self.emit(Event::Done { + id, + result: Err(WireError::Internal( + if self.signals.stop.load(Ordering::SeqCst) { + "QuickJS worker is closed".into() + } else { + "JS callback canceled: owning isolated eval ended".into() + }, + )), + }); + } + session.replies.borrow_mut().clear(); + Ok(()) + }) + } +} +#[derive(Clone, Default)] +struct ErrorContext { + module: Option, + eval: bool, +} +struct Pending<'js> { + id: u64, + parent: Option, + promise: Promise<'js>, + context: ErrorContext, + deadline: Option, + asked: Cell, + settle_depth: usize, +} +struct Session<'js> { + driver: Rc, + pending: RefCell>>>, + ready: Rc>>, + then: RefCell>>, + deferred_ready: RefCell>, + turn_deadline: Cell>, + posted: RefCell>, + capacity: RefCell)>>, + replies: RefCell>>>, + entries: RefCell>>, + deadline: Rc>>, + timed_out: Rc>, + failed: RefCell>, + root: Option, + root_finished: Cell, + facade: RefCell>>, + functions: RefCell>>, + js_depth: Cell, + reported_failure: Cell, + active_request: Cell>, +} +struct RequestScope<'a>(&'a Cell>, Option); +impl Drop for RequestScope<'_> { + fn drop(&mut self) { + self.0.set(self.1); + } +} +// Native closures retain Session. Break its JS roots before the context is freed, +// including panic unwinding; otherwise a cached facade could retain the runtime. +struct SessionCleanup<'js>(Rc>); +struct JsEntry<'a, 'js> { + session: &'a Session<'js>, + depth: bool, + budget: bool, +} +impl Drop for JsEntry<'_, '_> { + fn drop(&mut self) { + if self.depth { + self.session.js_depth.set(self.session.js_depth.get() - 1); + let deferred = std::mem::take(&mut *self.session.deferred_ready.borrow_mut()); + if !deferred.is_empty() { + self.session.ready.borrow_mut().extend(deferred); + } + } + if self.budget { + self.session.entries.borrow_mut().pop(); + self.session.update_deadline(); + } + } +} +impl Drop for SessionCleanup<'_> { + fn drop(&mut self) { + self.0.pending.borrow_mut().clear(); + self.0.facade.borrow_mut().take(); + self.0.functions.borrow_mut().take(); + self.0.then.borrow_mut().take(); + self.0.ready.borrow_mut().clear(); + self.0.deferred_ready.borrow_mut().clear(); + } +} +fn jobs_pending(ctx: &Ctx<'_>) -> bool { + unsafe { rquickjs::qjs::JS_IsJobPending(rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr())) } +} +impl<'js> Session<'js> { + fn enter(&self, deadline: Option, depth: bool) -> JsEntry<'_, 'js> { + let budget = self.driver.config.timeout_ms > 0; + if budget { + self.entries.borrow_mut().push(deadline); + self.update_deadline(); + } + if depth { + self.js_depth.set(self.js_depth.get() + 1); + } + JsEntry { + session: self, + depth, + budget, + } + } + fn finish_value( + &self, + id: u64, + ctx: &Ctx<'js>, + result: rquickjs::Result>, + context: &ErrorContext, + ) { + let result = result + .and_then(|value| { + if self.posted.borrow().contains(&id) { + Ok(Data::Null) + } else { + js_to_middle(ctx, value) + } + }) + .map_err(|e| self.error(ctx, e, context)); + self.finish(id, result); + } + fn update_deadline(&self) { + if self.driver.config.timeout_ms == 0 { + return; + } + let entries = self.entries.borrow(); + // An independent pending Promise must not shorten a new synchronous entry. + // Awaited jobs inherit their own active budget through the entry stack. + self.deadline.set(if entries.is_empty() { + self.pending + .borrow() + .values() + .filter_map(|p| p.deadline) + .min() + .or(self.turn_deadline.get()) + } else { + entries.iter().copied().flatten().min() + }); + } + fn error(&self, ctx: &Ctx<'js>, e: rquickjs::Error, context: &ErrorContext) -> WireError { + let parts = error::js_error_parts(ctx, e); + if self.timed_out.replace(false) { + return WireError::Timeout; + } + WireError::Guest { + parts, + module: context.module.as_ref().map(|m| m.module_id.clone()), + map: context + .module + .as_ref() + .and_then(|m| m.map_json.as_ref().map(|s| s.to_string())), + eval: context.eval, + } + } + fn finish(&self, id: u64, result: Result) { + self.pending.borrow_mut().remove(&id); + if result.is_err() { + let mut failed = self.failed.borrow_mut(); + failed.push_back(id); + if failed.len() > 256 { + failed.pop_front(); + } + drop(failed); + let mut canceled = Vec::new(); + self.pending.borrow_mut().retain(|_, p| { + if p.parent == Some(id) { + canceled.push(p.id); + false + } else { + true + } + }); + self.capacity.borrow_mut().retain(|(child, parent)| { + if *parent == Some(id) { + canceled.push(*child); + false + } else { + true + } + }); + for id in canceled { + self.finish( + id, + Err(WireError::Internal( + "JS callback canceled: owning call ended".into(), + )), + ); + } + } + let posted = self.posted.borrow_mut().remove(&id); + if posted { + self.driver.signals.posts.fetch_sub(1, Ordering::Relaxed); + if let Err(error) = result { + self.driver.emit(Event::Fatal(error)); + } + let available = + 128usize.saturating_sub(self.driver.signals.posts.load(Ordering::Relaxed)); + let ready = { + let mut capacity = self.capacity.borrow_mut(); + let n = available.min(capacity.len()); + capacity.drain(..n).map(|(id, _)| id).collect::>() + }; + for id in ready { + self.finish(id, Ok(Data::Null)); + } + } else { + self.driver.emit(Event::Done { id, result }); + } + if self.root == Some(id) { + self.root_finished.set(true) + } + } + fn command(&self, ctx: &Ctx<'js>, command: Command) -> bool { + match command { + Command::Pump => {} + Command::Stop => return false, + Command::Request(request) => self.request(ctx, request), + Command::HostReply { token, result } => { + if let Some(reply) = self.replies.borrow_mut().get_mut(&token) { + *reply = Some(result); + } + } + Command::Abort { id, message } => { + self.finish(id, Err(WireError::Internal(message))); + self.update_deadline(); + } + } + true + } + fn wait(&self, ctx: &Ctx<'js>) -> bool { + self.driver.flush_notifications(); + if !self.ready.borrow().is_empty() { + return true; + } + let command = match self.deadline.get() { + Some(deadline) => match self + .driver + .commands + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + Ok(command) => command, + Err(mpsc::RecvTimeoutError::Timeout) => return true, + Err(mpsc::RecvTimeoutError::Disconnected) => return false, + }, + None => match self.driver.commands.recv() { + Ok(command) => command, + Err(_) => return false, + }, + }; + self.command(ctx, command) + } + fn request(&self, ctx: &Ctx<'js>, request: Request) { + let _owner = RequestScope( + &self.active_request, + self.active_request.replace(Some(request.id)), + ); + let Request { + id, + op, + names, + deletions, + parent, + } = request; + if matches!(&op, Operation::Call { posted: true, .. }) { + self.posted.borrow_mut().insert(id); + } + if parent.is_some_and(|p| self.failed.borrow().contains(&p)) { + self.finish( + id, + Err(WireError::Internal( + "JS callback canceled: owning call ended".into(), + )), + ); + return; + } + if matches!(&op, Operation::Capacity) { + if self.driver.signals.posts.load(Ordering::Relaxed) < 128 { + self.finish(id, Ok(Data::Null)); + } else { + self.capacity.borrow_mut().push_back((id, parent)); + } + return; + } + let inherited = if self.driver.config.timeout_ms == 0 { + None + } else { + self.entries + .borrow() + .iter() + .copied() + .flatten() + .chain( + self.pending + .borrow() + .values() + .filter(|p| Some(p.id) == parent) + .filter_map(|p| p.deadline), + ) + .min() + }; + let deadline = inherited.or_else(|| { + (self.driver.config.timeout_ms > 0) + .then(|| Instant::now() + Duration::from_millis(self.driver.config.timeout_ms)) + }); + if deadline.is_some_and(|d| Instant::now() >= d) { + self.finish(id, Err(WireError::Timeout)); + return; + } + if self.js_depth.get() >= crate::engine::MAX_DEPTH { + self.finish( + id, + Err(WireError::Internal( + "maximum bridge re-entrancy depth exceeded".into(), + )), + ); + return; + } + let _entry = self.enter(deadline, true); + self.timed_out.set(false); + let await_result = matches!(&op, Operation::Eval { .. } | Operation::Call { .. }); + let context = ErrorContext { + eval: matches!(&op, Operation::Eval { .. }), + module: None, + }; + let (value, context) = match self.start(ctx, op, names, &deletions, context) { + Ok(result) => result, + Err(error) => return self.finish(id, Err(error)), + }; + if self.timed_out.replace(false) || deadline.is_some_and(|d| Instant::now() >= d) { + return self.finish(id, Err(WireError::Timeout)); + } + let result = (|| { + if await_result { + let promise = match value.as_promise() { + Some(promise) => Some(promise.clone()), + None if value.is_object() => { + let normalize: Function = ctx.globals().get("__asPromise")?; + normalize.call((value.clone(),))? + } + None => None, + }; + if let Some(promise) = promise { + if let Some(result) = promise.result::() { + return result.map(Some); + } + self.pending.borrow_mut().insert( + id, + Rc::new(Pending { + id, + parent, + promise: promise.clone(), + context: context.clone(), + deadline, + asked: Cell::new(false), + settle_depth: self.js_depth.get() - 1, + }), + ); + let ready = self.ready.clone(); + let notify = Function::new(ctx.clone(), move || { + ready.borrow_mut().push_back(id); + })?; + // Observe the native Promise with the pristine intrinsic, + // without a Rust executor or guest-overridable .then lookup. + let then = self.then.borrow().as_ref().unwrap().clone(); + then.call::<_, ()>((This(promise), notify.clone(), notify))?; + return Ok(None); + } + } + Ok(Some(value)) + })(); + if let Some(result) = result.transpose() { + self.finish_value(id, ctx, result, &context); + } + } + fn start( + &self, + ctx: &Ctx<'js>, + op: Operation, + names: Option>, + deletions: &[u64], + mut context: ErrorContext, + ) -> Result<(Value<'js>, ErrorContext), WireError> { + let prep = (|| -> rquickjs::Result<()> { + if !deletions.is_empty() { + let functions = self.functions.borrow().as_ref().unwrap().clone(); + for id in deletions { + functions.remove(*id as f64)?; + } + } + if names.is_none() && !matches!(&op, Operation::Eval { .. }) { + return Ok(()); + } + let cached = self + .facade + .borrow() + .as_ref() + .filter(|_| names.is_none()) + .cloned(); + if let Some(object) = cached { + ctx.globals().set("php", object)?; + } else { + let names = names.unwrap_or_default(); + ctx.eval::<(), _>(build_facade(&names))?; + let object = ctx.globals().get("php")?; + *self.facade.borrow_mut() = Some(object); + } + Ok(()) + })(); + prep.map_err(|e| self.error(ctx, e, &context))?; + let value = match op { + Operation::Eval { code, typescript } => { + let module = if typescript { + self.driver + .cache + .get_or_transpile(&code) + .map_err(|e| WireError::Syntax { + message: e.message, + line: e.line, + col: e.col, + })? + } else { + Transpiled { + module_id: "guest.js".into(), + js: Rc::from(code), + map_json: None, + } + }; + context.module = Some(module.clone()); + let mut options = rquickjs::context::EvalOptions::default(); + options.filename = Some(module.module_id.clone()); + ctx.eval_with_options(module.js.as_bytes(), options) + .map_err(|e| self.error(ctx, e, &context))? + } + Operation::Call { id, args, .. } => { + let result = (|| { + let function: Function = + self.functions.borrow().as_ref().unwrap().get(id as f64)?; + let mut call_args = rquickjs::function::Args::new(ctx.clone(), args.len()); + for arg in args { + call_args.push_arg(middle_into_js(ctx, arg)?)?; + } + function.call_arg(call_args) + })(); + result.map_err(|e| self.error(ctx, e, &context))? + } + Operation::Jobs(max) => { + let mut n = 0; + while n < max { + if self.deadline.get().is_some_and(|d| Instant::now() >= d) { + return Err(WireError::Timeout); + } + if !self.job(ctx, &context)? { + break; + } + n += 1; + self.settle(ctx); + } + Value::new_float(ctx.clone(), n as f64) + } + Operation::HasJobs => Value::new_bool(ctx.clone(), jobs_pending(ctx)), + Operation::Capacity => unreachable!("capacity requests do not enter JS"), + Operation::Roundtrip(value) => { + middle_into_js(ctx, value).map_err(|e| self.error(ctx, e, &context))? + } + }; + Ok((value, context)) + } + fn job(&self, ctx: &Ctx<'js>, context: &ErrorContext) -> Result { + let mut job_ctx = std::ptr::null_mut(); + let entry = self.enter(self.deadline.get(), true); + let result = unsafe { + rquickjs::qjs::JS_ExecutePendingJob( + rquickjs::qjs::JS_GetRuntime(ctx.as_raw().as_ptr()), + &mut job_ctx, + ) + }; + drop(entry); + if result < 0 { + return Err(self.error(ctx, rquickjs::Error::Exception, context)); + } + if self.timed_out.replace(false) { + return Err(WireError::Timeout); + } + Ok(result > 0) + } + fn settle(&self, ctx: &Ctx<'js>) { + if self.pending.borrow().is_empty() { + self.ready.borrow_mut().clear(); + return; + } + let ready = std::mem::take(&mut *self.ready.borrow_mut()); + for id in ready { + let item = self.pending.borrow().get(&id).cloned(); + let Some(item) = item else { continue }; + if item.settle_depth < self.js_depth.get() { + self.deferred_ready.borrow_mut().push(id); + continue; + } + if let Some(result) = item.promise.result::() { + self.finish_value(id, ctx, result, &item.context); + } + } + if self.driver.config.timeout_ms > 0 { + let expired = self + .pending + .borrow() + .values() + .filter(|p| p.deadline.is_some_and(|d| Instant::now() >= d)) + .map(|p| p.id) + .collect::>(); + for id in expired { + self.finish(id, Err(WireError::Timeout)); + } + } + self.update_deadline(); + } + fn step(&self, ctx: &Ctx<'js>, host: Option) -> bool { + if self.driver.signals.stop.load(Ordering::SeqCst) || self.root_finished.get() { + return false; + } + let had_pending = !self.pending.borrow().is_empty(); + self.settle(ctx); + if self.timed_out.replace(false) && !had_pending { + self.driver.signals.pump.store(false, Ordering::SeqCst); + self.driver.emit(Event::AsyncError(WireError::Timeout)); + } + // Commands are JS turns. Checkpoint their microtasks before admitting + // another command, but bound the batch so an infinite chain cannot + // starve PHP replies, cancellation or close. + for _ in 0..64 { + if !self.has_awaitable() + || !jobs_pending(ctx) + || self.root_finished.get() + || self.driver.signals.stop.load(Ordering::SeqCst) + { + break; + } + self.advance(ctx); + } + if self.root_finished.get() || self.driver.signals.stop.load(Ordering::SeqCst) { + return false; + } + self.settle(ctx); + // A nested job may consume an ancestor's reply. Do not sleep after + // that reply has become ready on this same worker thread. + if host.is_some_and(|id| self.replies.borrow().get(&id).is_some_and(Option::is_some)) { + return true; + } + self.driver.flush_notifications(); + match self.driver.commands.try_recv() { + Ok(command) => self.command(ctx, command), + Err(mpsc::TryRecvError::Disconnected) => false, + Err(mpsc::TryRecvError::Empty) => { + if self.has_awaitable() && jobs_pending(ctx) { + return true; + } + self.ask_loop(); + self.wait(ctx) + } + } + } + fn has_awaitable(&self) -> bool { + self.driver.signals.pump.load(Ordering::SeqCst) + || self + .pending + .borrow() + .values() + .any(|p| p.settle_depth >= self.js_depth.get()) + } + fn advance(&self, ctx: &Ctx<'js>) { + self.settle(ctx); + if !self.has_awaitable() { + return; + } + if !jobs_pending(ctx) { + self.turn_deadline.set(None); + return; + } + if self.turn_deadline.get().is_none() && self.driver.config.timeout_ms > 0 { + self.turn_deadline.set(Some( + Instant::now() + Duration::from_millis(self.driver.config.timeout_ms), + )); + self.update_deadline(); + } + let (id, context) = self + .pending + .borrow() + .values() + .find(|p| p.settle_depth >= self.js_depth.get()) + .map(|p| (Some(p.id), p.context.clone())) + .unwrap_or_default(); + if let Err(error) = self.job(ctx, &context) { + if let Some(id) = id { + self.finish(id, Err(error)); + self.update_deadline(); + } else { + self.driver.signals.pump.store(false, Ordering::SeqCst); + self.driver.emit(Event::AsyncError(error)); + } + } + if !jobs_pending(ctx) { + self.turn_deadline.set(None); + } + self.settle(ctx); + } + fn ask_loop(&self) { + for pending in self + .pending + .borrow() + .values() + .filter(|p| p.settle_depth >= self.js_depth.get()) + .filter(|p| !p.asked.replace(true)) + { + self.driver.emit(Event::NeedsLoop { id: pending.id }); + } + } + fn host( + &self, + ctx: &Ctx<'js>, + target: HostTarget, + args: Value<'js>, + ) -> rquickjs::Result> { + let args = js_to_middle(ctx, args)?; + let Data::Array(args) = args else { + return Err(Exception::throw_type( + ctx, + "host arguments must be an array", + )); + }; + let token = self.driver.token.get() + 1; + self.driver.token.set(token); + self.replies.borrow_mut().insert(token, None); + self.driver.emit(Event::Host { + owner: self.active_request.get(), + token, + target, + args, + }); + let result = (|| loop { + if self.driver.signals.stop.load(Ordering::SeqCst) { + return Err(Exception::throw_internal(ctx, "QuickJS worker closed")); + } + if self.deadline.get().is_some_and(|d| Instant::now() >= d) { + self.timed_out.set(true); + return Err(Exception::throw_internal( + ctx, + "JavaScript execution timed out", + )); + } + let reply = self + .replies + .borrow_mut() + .get_mut(&token) + .and_then(Option::take); + if let Some(reply) = reply { + return match reply { + Ok(value) => middle_into_js(ctx, value), + Err(error) => Err(error::throw_host_error(ctx, &error)), + }; + } + if !self.step(ctx, Some(token)) { + return Err(Exception::throw_internal(ctx, "QuickJS worker closed")); + } + })(); + self.replies.borrow_mut().remove(&token); + result + } +} +fn install<'js>(ctx: &Ctx<'js>, session: Rc>) -> rquickjs::Result<()> { + let host_session = session.clone(); + let host = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, name: String, args: Value<'js>| { + host_session.host(&ctx, HostTarget::Named(name), args) + }, + )?; + ctx.globals().set("__host", host)?; + let fn_session = session.clone(); + let invoke = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, id: f64, args: Value<'js>| { + fn_session.host(&ctx, HostTarget::Function(id as u64), args) + }, + )?; + ctx.globals().set("__php_invoke", invoke)?; + let message_driver = session.driver.clone(); + let post = Function::new( + ctx.clone(), + move |ctx: Ctx<'js>, value: Value<'js>| -> rquickjs::Result<()> { + let queue = &message_driver.messages; + let capacity = queue.lock().unwrap_or_else(|e| e.into_inner()).remaining(); + let (data, bytes) = js_to_data(&ctx, value, capacity)?; + let mut queue = queue.lock().unwrap_or_else(|e| e.into_inner()); + let empty = queue.is_empty(); + queue + .push(data, bytes) + .map_err(|e| Exception::throw_type(&ctx, e))?; + drop(queue); + if empty && message_driver.signals.pump.load(Ordering::SeqCst) { + message_driver.emit(Event::Messages); + } + Ok(()) + }, + )?; + let quickjs = rquickjs::Object::new(ctx.clone())?; + let failure_session = session.clone(); + quickjs.set( + "reportError", + Function::new(ctx.clone(), move |ctx: Ctx<'js>, value: Value<'js>| { + if !failure_session.reported_failure.replace(true) { + let thrown = ctx.throw(value); + let parts = error::js_error_parts(&ctx, thrown); + failure_session.driver.emit(Event::Fatal(WireError::Guest { + parts, + module: None, + map: None, + eval: false, + })); + } + })?, + )?; + let clock_origin = Instant::now(); + quickjs.set( + "now", + Function::new(ctx.clone(), move || { + clock_origin.elapsed().as_secs_f64() * 1000.0 + })?, + )?; + quickjs.set("postMessage", post)?; + ctx.globals().set("quickjs", quickjs)?; + ctx.eval::<(),_>("Object.freeze(globalThis.quickjs); Object.defineProperty(globalThis,'quickjs',{value:quickjs,writable:false,configurable:false})")?; + ctx.eval::<(), _>(RUNTIME_JS)?; + *session.functions.borrow_mut() = Some(ctx.globals().get("__jsFunctions")?); + Ok(()) +} diff --git a/stubs/php_quickjs.stubs.php b/stubs/php_quickjs.stubs.php index 1cbc4ca..4490eac 100644 --- a/stubs/php_quickjs.stubs.php +++ b/stubs/php_quickjs.stubs.php @@ -1,5 +1,7 @@ + */ + public function manifest(): mixed {} + + /** Stop this runtime; future entries fail. */ + public function close(): void {} - /** Evaluate JS source and marshal the result back to a PHP value. */ - public function eval(string $code): mixed {} + /** Whether Promise jobs are ready (shared mode only). Does not include host I/O. */ + public function hasPendingJobs(): bool {} - /** The registration manifest: a list of `['name' => string, 'types' => ?string]`. */ - public function manifest(): array {} + /** Execute at most maxJobs ready jobs without waiting for I/O; returns the count. */ + public function executePendingJobs(int $maxJobs = 100): int {} + + /** + * Drain bounded messages emitted by quickjs.postMessage() without entering JS. + * @return list + */ + public function drainMessages(): mixed {} + /** Await a message batch and enable automatic jobs; one consumer, non-isolated runtime. */ + public function awaitMessages(): mixed {} - /** Generate a TypeScript `.d.ts` declaration for the `php` global. */ + /** Generate TypeScript `.d.ts` declarations for the `php` and `quickjs` globals. */ public function dts(): string {} /** Grant JS an opaque integer handle to a live PHP value. */ @@ -49,37 +77,63 @@ public function revoke(int $handle): bool {} public function roundtrip(mixed $value): mixed {} } +} + namespace Js { /** * A JS function handed to PHP. Invoke it like any callable: `$cb(...$args)`. */ class Callback { + /** Native constructor exists, but callbacks are created only by the bridge. */ + public function __construct() {} + + /** Invoke the JS function, awaiting a returned Promise. */ public function __invoke(mixed ...$args): mixed {} + + /** Invoke the JS function, awaiting a returned Promise. */ public function call(mixed ...$args): mixed {} + /** Queue a bounded call; discard its value, deliver failures to awaitMessages(). */ + public function post(mixed ...$args): void {} } } namespace { /** Base class for every exception thrown by the extension. */ - class QuickJSException extends \Exception {} + class QuickJSException extends \Exception + { + /** Native constructor exists; exceptions are created by the extension. */ + public function __construct() {} + } /** * A JavaScript/TypeScript error escaped `eval`. `getMessage()` is the clean - * error text and `getFile()`/`getLine()` carry the original TS location. + * error text and `getFile()`/`getLine()` carry the original source location + * (guest.ts after remapping, or guest.js with typescript: false). */ class QuickJSEvalException extends QuickJSException { + /** Native constructor exists; exceptions are created by the extension. */ + public function __construct() {} + /** The JS error constructor name (e.g. "TypeError"), or the PHP class for a re-surfaced host error. */ public function getJsName(): string {} - /** The stack trace, remapped to TypeScript coordinates and filtered to guest frames. */ + /** The remapped guest-only TypeScript stack, or the original direct JavaScript stack. */ public function getJsStack(): string {} } /** The wall-clock deadline tripped during `eval`. */ - class QuickJSTimeoutException extends QuickJSException {} + class QuickJSTimeoutException extends QuickJSException + { + /** Native constructor exists; exceptions are created by the extension. */ + public function __construct() {} + } /** The memory limit tripped during `eval`. */ - class QuickJSMemoryException extends QuickJSException {} + class QuickJSMemoryException extends QuickJSException + { + /** Native constructor exists; exceptions are created by the extension. */ + public function __construct() {} + } } diff --git a/tests/cgi/notification.php b/tests/cgi/notification.php new file mode 100644 index 0000000..4edbcf1 --- /dev/null +++ b/tests/cgi/notification.php @@ -0,0 +1,21 @@ +register('echo', static fn($value) => $value); +for ($i = 0; $i < 10; ++$i) { + if ($js->eval('php.echo(42)', false) !== 42) throw new RuntimeException('CGI result mismatch'); +} +$batch = Amp\async(static fn() => $js->awaitMessages()); +Amp\delay(.001); +$js->eval('Promise.resolve().then(() => quickjs.postMessage(42)); null', false); +if ($batch->await() !== [42]) throw new RuntimeException('CGI message pump mismatch'); +$js->close(); +// Request shutdown force-closes suspended Fibers. Its PHP exception must escape +// the native wait loop instead of retrying suspend forever. +$unfinished = new QuickJS(); +Amp\async(static fn() => $unfinished->awaitMessages())->ignore(); +$hostWait = new QuickJS(); +$hostWait->register('wait', static fn() => Amp\delay(60)); +Amp\async(static fn() => $hostWait->eval('php.wait()', false))->ignore(); +Amp\delay(.001); +echo "CGI notifications PASS\n"; diff --git a/tests/cgi/run.php b/tests/cgi/run.php new file mode 100644 index 0000000..62bf090 --- /dev/null +++ b/tests/cgi/run.php @@ -0,0 +1,18 @@ + ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, + [...getenv(), 'REDIRECT_STATUS' => '1', 'REQUEST_METHOD' => 'GET', 'SCRIPT_FILENAME' => __DIR__ . '/notification.php']); +if (!is_resource($process)) throw new RuntimeException('Cannot start php-cgi'); +fclose($pipes[0]); +$output = stream_get_contents($pipes[1]); +$errors = stream_get_contents($pipes[2]); +fclose($pipes[1]); fclose($pipes[2]); +$status = proc_close($process); +if ($status !== 0 || !str_contains($output, "CGI notifications PASS\n") || str_contains($output, 'Fatal error')) { + throw new RuntimeException("CGI failed ($status): $output $errors"); +} +echo "CGI notifications PASS\n"; diff --git a/tests/php/02_marshal_roundtrip.php b/tests/php/02_marshal_roundtrip.php index 4b4c4be..2e356bf 100644 --- a/tests/php/02_marshal_roundtrip.php +++ b/tests/php/02_marshal_roundtrip.php @@ -33,4 +33,22 @@ $bytes = "\x00\x01\x02\xff"; eq($bytes, $js->eval('new Uint8Array([0,1,2,255])'), 'Uint8Array -> binary string'); + +$baseline = $js->eval('__jsFnCount()'); +throws(fn() => $js->eval('[() => 42, Symbol("bad")]'), Throwable::class, 'failed conversion rejects unsupported JS value'); +eq($baseline, $js->eval('__jsFnCount()'), 'failed conversion releases registered JS functions'); +eq(16777217, strlen($js->eval('"x".repeat(16777217)')), 'generic eval is not limited by transport byte cap'); +$call = $js->eval('(fn) => fn()'); +$own = $js->eval('() => 99'); +eq(99, $call($own), 'same-engine callbacks remain supported'); +$other = new QuickJS(); +$foreign = $other->eval('() => 42'); +throws(fn() => $call($foreign), Throwable::class, 'foreign callback arguments rejected'); +$captured = new stdClass(); +$weak = WeakReference::create($captured); +$closure = fn() => $captured; +try { $call($closure, new stdClass()); } catch (Throwable) {} +unset($closure, $captured); +eq(null, $weak->get(), 'failed argument conversion releases PHP closures'); + done(); diff --git a/tests/php/03_register_dispatch.php b/tests/php/03_register_dispatch.php index 0d011bf..2226f89 100644 --- a/tests/php/03_register_dispatch.php +++ b/tests/php/03_register_dispatch.php @@ -16,7 +16,7 @@ $js->register('fetchUser', fn(int $id) => ['id' => $id, 'name' => 'Ada', 'orders' => [1, 2, 3]]); $js->register('echo', fn($x) => $x); -// JS -> PHP -> JS through the msgpack __host bridge. +// JS -> PHP -> JS through the direct bridge. eq(7, $js->eval('php.math.add(3, 4)'), 'math.add reenters PHP'); eq('Ada has 3 orders', $js->eval(' const u = php.fetchUser(42); @@ -26,7 +26,7 @@ $js->eval('php.log.info("hello from js")'); eq(['hello from js'], $log, 'side-effecting callback ran in PHP'); -// Every marshalable type survives the msgpack round-trip JS->PHP->JS. +// Every marshalable type survives the direct round-trip JS->PHP->JS. eq(true, $js->eval('php.echo(true)'), 'bool through bridge'); eq(-12345, $js->eval('php.echo(-12345)'), 'negative int through bridge'); eq(1 << 40, $js->eval('php.echo(' . (1 << 40) . ')'), 'large int through bridge'); @@ -36,6 +36,19 @@ eq(['a' => 1, 'b' => [2, 3]], $js->eval('php.echo({a:1, b:[2,3]})'), 'nested object through bridge'); eq(null, $js->eval('php.echo(null)'), 'null through bridge'); eq("\x00\xff", $js->eval('php.echo(new Uint8Array([0,255]))'), 'bytes through bridge'); +eq(true, $js->eval('(() => { + const bytes = new Uint8Array(13 * 1024 * 1024); + bytes[0] = 255; + bytes[bytes.length - 1] = 254; + const result = php.echo(bytes); + return result instanceof Uint8Array && result.length === bytes.length + && result[0] === 255 && result[result.length - 1] === 254; +})()'), 'large binary payload through direct bridge'); +eq(true, $js->eval('(() => { + const text = "a".repeat(1024 * 1024); + return php.echo(text) === text; +})()'), 'large text payload through direct bridge'); +eq('undefined', $js->eval('typeof __mp'), 'no JS codec is installed'); // The facade is frozen: guests cannot shadow capabilities. eq(true, $js->eval('Object.isFrozen(php)'), 'php is frozen'); @@ -48,7 +61,8 @@ (bool) $js->eval('(() => { try { php.echo; return true; } catch(e){ return false; } })()'), 'registered capability is reachable' ); -$threw = $js->eval('(() => { try { __host("not.registered", __mp.encode([])); return false; } catch(e){ return true; } })()'); +$threw = $js->eval('(() => { try { __host("not.registered", []); return false; } catch(e){ return true; } })()'); eq(true, $threw, 'unknown capability throws in JS'); +eq(true, $js->eval('(() => { try { __host("echo", 1); return false; } catch(e) { return e instanceof TypeError; } })()'), 'host rejects non-array arguments'); done(); diff --git a/tests/php/04_callbacks_bidir.php b/tests/php/04_callbacks_bidir.php index 09c509d..336b729 100644 --- a/tests/php/04_callbacks_bidir.php +++ b/tests/php/04_callbacks_bidir.php @@ -48,7 +48,7 @@ '), 'JS function survives a round trip through PHP'); // --- Mutual recursion / nesting (PHP -> JS -> PHP -> JS) ----------------- -$js->register('callTwice', fn(callable $fn, $x) => $fn($fn($x))); +$js->register('callTwice', fn(Js\Callback $fn, $x) => $fn->call($fn($x))); eq(12, $js->eval('php.callTwice(n => php.makeAdder(3)(n), 6)'), 'nested JS->PHP->JS->PHP callbacks'); diff --git a/tests/php/07_sandbox_timeout.php b/tests/php/07_sandbox_timeout.php index 3de07c9..e1c21bc 100644 --- a/tests/php/07_sandbox_timeout.php +++ b/tests/php/07_sandbox_timeout.php @@ -34,6 +34,13 @@ 'alloc bomb trips the memory limit' ); +$isolatedMem = new QuickJS(memoryLimit: 2 * 1024 * 1024, isolated: true); +for ($i = 0; $i < 2; ++$i) { + throws(fn() => $isolatedMem->eval('let a = []; while (true) { a.push(new Array(100000).fill(0)); }'), + QuickJSMemoryException::class, 'fresh isolated runtime enforces its memory limit'); + eq(42, $isolatedMem->eval('42'), 'isolated runtime recovers after memory exhaustion'); +} + // --- Unbounded by default ------------------------------------------------ $free = new QuickJS(); eq(500500, $free->eval('let s=0; for (let i=0;i<=1000;i++) s+=i; s'), 'no limits by default'); diff --git a/tests/php/08_dts_generation.php b/tests/php/08_dts_generation.php index 058a1af..5078151 100644 --- a/tests/php/08_dts_generation.php +++ b/tests/php/08_dts_generation.php @@ -19,6 +19,7 @@ // --- dts() is generated from the same manifest --------------------------- $dts = $js->dts(); ok(str_contains($dts, 'declare const php:'), 'dts declares the php global'); +ok(str_contains($dts, 'declare const quickjs:'), 'dts declares the native message API'); ok(str_contains($dts, 'db: {'), 'dts nests dotted names into namespaces'); ok(str_contains($dts, 'query(handle: number, sql: string): unknown[];'), 'typed signature emitted verbatim'); ok(str_contains($dts, 'execute(...args: any[]): any;'), 'untyped leaf falls back to any'); diff --git a/tests/php/10_scope_isolation.php b/tests/php/10_scope_isolation.php index f1e9ea7..130a31c 100644 --- a/tests/php/10_scope_isolation.php +++ b/tests/php/10_scope_isolation.php @@ -59,4 +59,14 @@ $iso->eval('php.keep(() => 1)'); throws(fn() => $held(), \Throwable::class, 'stored callback rejected after its eval (isolated)'); +// Old ids must not resolve or delete a function in a later isolated realm. +$iso->register('tryOld', function () use (&$held) { + throws(fn() => $held(), Throwable::class, 'old callback rejected inside a later eval'); + $held = null; +}); +eq(42, $iso->eval('php.apply(n => { php.tryOld(); return n * 7; }, 6)'), 'dropping old callback preserves the current callback'); +$old = $iso->eval('() => 1'); +throws(fn() => $iso->roundtrip($old), Throwable::class, 'ended callback cannot be round-tripped into a fresh realm'); +$iso->register('returnOld', fn() => $old); +throws(fn() => $iso->eval('php.returnOld()'), Throwable::class, 'old callback cannot be marshaled into a new realm'); done(); diff --git a/tests/php/11_jobs.php b/tests/php/11_jobs.php new file mode 100644 index 0000000..1d3890a --- /dev/null +++ b/tests/php/11_jobs.php @@ -0,0 +1,113 @@ +hasPendingJobs(), 'new runtime has no jobs'); +eq(0, $js->executePendingJobs(), 'empty queue returns immediately'); + +eq(42, $js->eval('(async () => 42)()'), 'fulfilled async eval is awaited automatically'); +eq(42, $js->eval('(async () => { await 0; return 42; })()'), 'async eval drains its own jobs'); +$asyncDouble = $js->eval('async n => { await 0; return n * 2; }'); +eq(42, $asyncDouble(21), 'async JS callback returns its fulfilled value to PHP'); +throws( + fn() => $js->eval('(async () => { await 0; throw new Error("async boom"); })()'), + QuickJSEvalException::class, + 'async rejection becomes a PHP evaluation exception' +); +eq(42, $js->eval('({ then(resolve) { resolve(42); } })'), 'thenables are awaited'); +eq(42, $js->eval('let chain = Promise.resolve(42); for (let i = 0; i < 100; i++) chain = chain.then(value => value); chain'), 'settled Promise at quantum boundary needs no event loop'); +eq(42, $js->eval('({ get then() { if (this.read) throw new Error("then read twice"); this.read = true; return resolve => resolve(42); } })'), 'thenable getter is read once'); +eq(42, (new QuickJS(isolated: true))->eval('(async () => { await 0; return 42; })()'), 'isolated eval awaits its Promise'); +$stop = new QuickJS(); +eq(42, $stop->eval('globalThis.detached = 0; Promise.resolve().then(() => { Promise.resolve().then(() => { detached = 1; }); return 42; })'), 'await returns its fulfilled Promise value'); +eq(1, $stop->eval('detached'), 'await may also drain later detached microtasks'); +$stop->executePendingJobs(); +eq(1, $stop->eval('detached'), 'explicit drain remains valid after automatic microtasks'); +throws(fn() => (new QuickJS())->eval('new Promise(() => {})'), Throwable::class, 'external wait without autoloaded Revolt fails clearly'); + +$js->eval('globalThis.answer = 0; Promise.resolve(21).then(n => { answer = n * 2; }); void 0;'); +eq(0, $js->eval('answer'), 'eval does not implicitly drain jobs'); +eq(true, $js->hasPendingJobs(), 'Promise reaction is pending'); +eq(1, $js->executePendingJobs(1), 'one job executed'); +eq(42, $js->eval('answer'), 'reaction executed'); +eq(false, $js->hasPendingJobs(), 'queue drained'); + +$resolve = null; +$js->register('capture', function ($fn) use (&$resolve) { $resolve = $fn; }); +$js->eval('globalThis.later = 0; new Promise(resolve => php.capture(resolve)).then(n => { later = n; }); void 0;'); +eq(false, $js->hasPendingJobs(), 'unresolved Promise is not a ready job'); +$resolve(17); +eq(true, $js->hasPendingJobs(), 'host resolution enqueues a reaction'); +$js->executePendingJobs(); +eq(17, $js->eval('later'), 'host-resolved Promise completes'); + +$js->eval('globalThis.failure = null; Promise.resolve().then(() => { throw new Error("expected"); }).catch(e => { failure = e.message; }); void 0;'); +$js->executePendingJobs(); +eq('expected', $js->eval('failure'), 'rejections retain JS catch semantics'); + +$js->eval('globalThis.jobs = 0; globalThis.keepGoing = true; function again() { jobs++; if (keepGoing) Promise.resolve().then(again); } Promise.resolve().then(again); void 0;'); +eq(7, $js->executePendingJobs(7), 'self-scheduling queue is bounded'); +eq(7, $js->eval('jobs'), 'job limit is exact'); +eq(true, $js->hasPendingJobs(), 'remaining work stays queued'); +$js->eval('keepGoing = false; void 0;'); +$js->executePendingJobs(); +eq(false, $js->hasPendingJobs(), 'queue can finish on a later turn'); +throws(fn() => $js->executePendingJobs(0), Throwable::class, 'zero budget rejected'); +throws(fn() => $js->executePendingJobs(-1), Throwable::class, 'negative budget rejected'); + +$js->register('nested', fn() => $js->executePendingJobs()); +throws(fn() => $js->eval('php.nested()'), Throwable::class, 'reentrant draining rejected instead of deadlocking'); +$js->register('apply', fn($fn) => $fn(6)); +$js->eval('globalThis.nestedResult = 0; Promise.resolve().then(() => { nestedResult = php.apply(n => n * 7); }); void 0;'); +$js->executePendingJobs(); +eq(42, $js->eval('nestedResult'), 'jobs can synchronously call PHP and JS'); + +$isolated = new QuickJS(isolated: true); +throws(fn() => $isolated->hasPendingJobs(), Throwable::class, 'isolated jobs rejected'); +throws(fn() => $isolated->executePendingJobs(), Throwable::class, 'isolated draining rejected'); + +$limited = new QuickJS(timeoutMs: 20); +$limited->eval('Promise.resolve().then(() => { while (true) {} }); void 0;'); +throws(fn() => $limited->executePendingJobs(), QuickJSTimeoutException::class, 'single runaway job observes execution timeout'); +eq(3, $limited->eval('1 + 2'), 'engine recovers after job timeout'); +// Host calls cannot be interrupted, but the next job must respect the batch budget. +$batch = new QuickJS(timeoutMs: 20); +$slowCalls = 0; +$batch->register('slow', function () use (&$slowCalls) { ++$slowCalls; usleep(40000); }); +$batch->eval('for (let i = 0; i < 3; i++) Promise.resolve().then(() => php.slow()); void 0;'); +throws(fn() => $batch->executePendingJobs(), QuickJSTimeoutException::class, 'short jobs enforce wall time between host calls'); +eq(1, $slowCalls, 'expired batch does not execute the next host call'); +eq(true, $batch->hasPendingJobs(), 'timeout preserves jobs not yet executed'); +eq(3, $batch->eval('1 + 2'), 'engine recovers after host-call batch timeout'); + +// Callback-only event loops must not require eval() to release callback entries. +$cleanup = new QuickJS(); +[$make, $count] = $cleanup->eval('[() => () => 1, () => __jsFnCount()]'); +for ($i = 0; $i < 20; ++$i) { + $temporary = $make(); + unset($temporary); +} +eq(2, $count(), 'outer callback entries flush released callback references'); +$observedCount = null; +$cleanup->register('observe', function ($n) use (&$observedCount) { $observedCount = $n; }); +$cleanup->eval('Promise.resolve().then(() => php.observe(__jsFnCount())); void 0;'); +$temporary = $make(); +unset($temporary); +$cleanup->executePendingJobs(); +eq(2, $observedCount, 'job batches flush released callback references before execution'); +// A completed isolated entry owns neither detached jobs nor their closures. +$isolated = new QuickJS(isolated: true, timeoutMs: 20); +$effects = 0; +$isolated->register('effect', function () use (&$effects) { ++$effects; }); +eq(1, $isolated->eval('Promise.resolve().then(() => php.effect()); 1'), 'detached job is not executed eagerly'); +eq(42, $isolated->eval('(async () => { await 0; return 42; })()'), 'next isolated entry awaits its own jobs'); +eq(0, $effects, 'detached job is discarded with its isolated runtime'); +throws(fn() => $isolated->eval('Promise.resolve().then(() => php.effect()); throw new Error("stop")'), QuickJSEvalException::class, 'isolated entry can fail with jobs queued'); +$isolated->eval('(async () => { await 0; })()'); +eq(0, $effects, 'failed isolated entry discards detached jobs'); +throws(fn() => $isolated->eval('Promise.resolve().then(() => php.effect()); while (true) {}'), QuickJSTimeoutException::class, 'isolated entry times out with jobs queued'); +$isolated->eval('(async () => { await 0; })()'); +eq(0, $effects, 'timed-out isolated entry discards detached jobs'); +$isolated->register('apply', fn($fn) => $fn(6)); +eq(42, $isolated->eval('(async () => { await 0; return php.apply(async n => { await 0; return n * 7; }); })()'), 'isolated nested async callback uses the active runtime'); +done(); diff --git a/tests/php/12_fibers.php b/tests/php/12_fibers.php new file mode 100644 index 0000000..0cdfb8e --- /dev/null +++ b/tests/php/12_fibers.php @@ -0,0 +1,60 @@ +eval('(n) => n * 2'); +$fiber = new Fiber(function () use ($js, $callback) { + eq(3, $js->eval('1 + 2'), 'main-stack engine runs in a Fiber'); + eq(42, $callback(21), 'main-stack callback runs in a Fiber'); + $js->eval('globalThis.value = 0; Promise.resolve().then(() => { value = 17; }); void 0;'); + Fiber::suspend(); + $js->executePendingJobs(); + eq(17, $js->eval('value'), 'jobs run after Fiber resumption'); +}); +$fiber->start(); +eq(true, $js->hasPendingJobs(), 'ready jobs visible from main stack'); +$fiber->resume(); +eq(6, $callback(3), 'callback returns to main stack'); + +$inside = null; +(new Fiber(function () use (&$inside) { $inside = new QuickJS(); $inside->eval('globalThis.createdInFiber = 23;'); }))->start(); +eq(23, $inside->eval('createdInFiber'), 'engine outlives its creation Fiber'); + +$other = new QuickJS(); +$otherCallback = $other->eval('(n) => n + 100'); +$js->register('other', fn($n) => $otherCallback($n)); +eq(107, $js->eval('php.other(7)'), 'cross-engine callback uses its own context'); + +$js->register('suspendValue', fn() => Fiber::suspend('inside JS')); +$suspending = new Fiber(function () use ($js) { + eq(42, $js->eval('php.suspendValue(); 42'), 'host callback resumes on its owning Fiber'); +}); +eq('inside JS', $suspending->start(), 'host callback may suspend like php-tokio'); +throws(fn() => $js->eval('1'), Throwable::class, 'another Fiber cannot enter a suspended engine'); +$suspending->resume(); +eq(true, $suspending->isTerminated(), 'owning Fiber finishes after resumption'); +eq(3, $js->eval('1 + 2'), 'engine remains usable after Fiber resumption'); + +$js->register('apply', fn($fn) => $fn()); +(new Fiber(function () use ($js) { + eq(9, $js->eval('php.apply(() => 9)'), 'same-engine synchronous reentrancy still works'); +}))->start(); +$isolated = new QuickJS(isolated: true); +(new Fiber(function () use ($isolated) { + eq(42, $isolated->eval('6 * 7'), 'isolated context can be created on another Fiber stack'); +}))->start(); +$js->register('reenterEval', fn() => $js->eval('1')); +throws(fn() => $js->eval('php.reenterEval()'), Throwable::class, 'reentrant eval is rejected instead of deadlocking'); +eq(3, $js->eval('1 + 2'), 'engine recovers after rejected eval'); + +$loop = $js->eval('() => { while (true) {} }'); +throws(fn() => $loop(), QuickJSTimeoutException::class, 'saved callbacks obey the execution timeout'); +eq(3, $js->eval('1 + 2'), 'engine recovers after callback timeout'); + +$short = new QuickJS(timeoutMs: 20); +$short->register('slow', function () { usleep(50000); return 42; }); +$slow = $short->eval('() => php.slow()'); +throws(fn() => $slow(), QuickJSTimeoutException::class, 'blocking callback timeout detected on return'); +eq(3, $short->eval('1+2'), 'engine recovers after blocking callback timeout'); + +done(); diff --git a/tests/php/13_messages.php b/tests/php/13_messages.php new file mode 100644 index 0000000..6e7525d --- /dev/null +++ b/tests/php/13_messages.php @@ -0,0 +1,31 @@ +eval('(value) => quickjs.postMessage(value)'); +foreach (['', "nul\0tail", 'Привет 🌍', "\xff\xfe"] as $value) { + $send($value); + eq([$value], $js->drainMessages(), 'message preserves data and bytes'); +} +eq([], $js->drainMessages(), 'drain clears the queue'); +eq(true, $js->eval('Object.isFrozen(quickjs) && Object.getOwnPropertyDescriptor(globalThis, "quickjs").writable === false'), 'message API is immutable'); +eq('undefined', $js->eval('typeof __quickjsEmitAsync'), 'beta emit API removed'); +eq(false, method_exists($send, 'dispatch'), 'beta dispatch API removed'); + +$js->eval('quickjs.postMessage({value: 1})'); +throws(fn() => $js->eval('quickjs.postMessage(() => 1)'), QuickJSEvalException::class, 'function rejected'); +eq([['value' => 1]], $js->drainMessages(), 'earlier messages survive a failed emission'); +throws(fn() => $js->eval('const cycle = {}; cycle.self = cycle; quickjs.postMessage(cycle)'), QuickJSEvalException::class, 'cycle rejected'); +eq([], $js->drainMessages(), 'cycle did not enter queue'); + +$js->eval('quickjs.postMessage({get value() { quickjs.postMessage("nested"); return 2; }})'); +eq(['nested', ['value' => 2]], $js->drainMessages(), 'getter can emit without borrowing queue'); +$js->eval('quickjs.postMessage(new Uint8Array(600))'); +throws(fn() => $js->eval('quickjs.postMessage(new Uint8Array(600))'), QuickJSEvalException::class, 'aggregate byte limit enforced'); +eq(1, count($js->drainMessages()), 'overflow preserves queued result'); +$js->eval('quickjs.postMessage(42)'); +eq([42], $js->drainMessages(), 'queue recovers after drain'); + +done(); diff --git a/tests/php/14_async.php b/tests/php/14_async.php new file mode 100644 index 0000000..30c2a39 --- /dev/null +++ b/tests/php/14_async.php @@ -0,0 +1,140 @@ +register('later', static function ($resolve): void { + Revolt\EventLoop::delay(0.001, static fn() => $resolve(42)); +}); +eq(42, $js->eval('new Promise(resolve => php.later(resolve))'), 'main flow awaits external resolution'); +eq(42, Amp\async(fn() => $js->eval('new Promise(resolve => php.later(resolve))'))->await(), 'Amp Fiber awaits external resolution'); +eq([], array_values(array_filter(Revolt\EventLoop::getIdentifiers(), Revolt\EventLoop::isReferenced(...))), 'completion leaves no referenced watcher'); + +$js->register('sleep', static function (): int { Amp\delay(0.001); return 42; }); +eq(42, Amp\async(fn() => $js->eval('(async () => php.sleep())()'))->await(), 'registered PHP callbacks may await Amp I/O'); + +// An event-loop Fiber must receive the callback result, including async results. +foreach (['resolve => { resolve(42); return 7; }', 'async resolve => { await 0; resolve(42); return 7; }'] as $source) { + $callback = $js->eval($source); + $future = null; + $js->register('later', static function ($resolve) use ($callback, &$future): void { + $future = Amp\async(static fn() => $callback($resolve)); + }); + eq(42, $js->eval('new Promise(resolve => php.later(resolve))'), 'queued callback resolves the owning Promise'); + eq(7, $future->await(), 'queued callback returns its value to the calling Fiber'); +} + +$failing = $js->eval('() => { throw new TypeError("queued failure"); }'); +$js->register('later', static function ($resolve) use ($failing, &$future): void { + $future = Amp\async(static function () use ($failing, $resolve): void { + try { $failing(); } finally { $resolve(42); } + }); +}); +eq(42, $js->eval('new Promise(resolve => php.later(resolve))'), 'owner continues after a queued callback fails'); +throws(fn() => $future->await(), QuickJSEvalException::class, 'queued callback delivers its exception to the calling Fiber'); + +$futures = []; +$js->register('all', static function ($callback) use (&$futures): void { + for ($i = 0; $i < 10; ++$i) { + $futures[] = Amp\async(static fn() => $callback($i)); + } +}); +eq(45, $js->eval('new Promise(resolve => { let count = 0, sum = 0; php.all(n => { sum += n; if (++count === 10) resolve(sum); return n * 2; }); })'), 'multiple event-loop callbacks settle one Promise'); +eq(range(0, 18, 2), array_map(static fn($future) => $future->await(), $futures), 'each queued caller receives its own result'); + +$first = $js->eval('() => new Promise(resolve => { globalThis.resolveFirst = resolve; })'); +$second = $js->eval('resolveOwner => { resolveFirst(41); resolveOwner(7); return 42; }'); +$js->register('startConcurrent', static function ($resolveOwner) use ($first, $second, &$futures): void { + $futures = [ + Amp\async(static fn() => $first()), + Amp\async(static fn() => $second($resolveOwner)), + ]; +}); +eq(7, $js->eval('new Promise(resolve => php.startConcurrent(resolve))'), 'second callback can settle the first pending Promise'); +eq([41, 42], array_map(static fn($future) => $future->await(), $futures), 'concurrent callback results resume independently'); + +$waiting = $js->eval('() => new Promise(resolve => { globalThis.finishWaiting = resolve; })'); +$js->register('startPending', static function ($resolveOwner) use ($waiting, &$future): void { + $future = Amp\async(static fn() => $waiting()); + Revolt\EventLoop::defer(static fn() => $resolveOwner(8)); +}); +eq(8, $js->eval('new Promise(resolve => php.startPending(resolve))'), 'owner may finish while a callback Promise is pending'); +$js->eval('finishWaiting(43)'); +$js->executePendingJobs(); +eq(43, $future->await(), 'pending callback survives the owner entry'); + +$fair = new QuickJS(timeoutMs: 1000); +$stop = $fair->eval('() => { globalThis.stopped = true; }'); +$fair->register('scheduleStop', static function () use ($stop): void { + Revolt\EventLoop::delay(0.001, static fn() => $stop()); +}); +eq(1, $fair->eval('globalThis.stopped = false; php.scheduleStop(); new Promise(resolve => { + function spin() { if (stopped) resolve(1); else Promise.resolve().then(spin); } + spin(); +})'), 'microtask chain yields to Revolt timers'); + +$limited = new QuickJS(timeoutMs: 20); +$start = hrtime(true); +throws(fn() => $limited->eval('new Promise(() => {})'), QuickJSTimeoutException::class, 'external wait obeys timeoutMs'); +ok((hrtime(true) - $start) / 1e6 < 1000, 'deadline wakes a Promise without external events'); +eq(3, $limited->eval('1 + 2'), 'engine recovers after an external wait timeout'); +eq([], array_values(array_filter(Revolt\EventLoop::getIdentifiers(), Revolt\EventLoop::isReferenced(...))), 'timeout leaves no referenced watcher'); +throws(fn() => Amp\async(fn() => $limited->eval('new Promise(() => {})'))->await(), QuickJSTimeoutException::class, 'deadline wakes an Amp Fiber too'); +eq(3, $limited->eval('1 + 2'), 'engine recovers after an Amp Fiber timeout'); + +// The worker may finish a JS callback while the PHP event loop is blocked. +// Its execution is no longer delayed until PHP returns from usleep(). +$blocked = new QuickJS(timeoutMs: 100); +$blocked->register('start', static function ($callback) use (&$future): void { + $future = Amp\async(static fn() => $callback()); + Revolt\EventLoop::queue(static fn() => usleep(200000)); +}); +throws(fn() => Amp\async(fn() => $blocked->eval('new Promise(() => php.start(() => { globalThis.completed = true; return 42; }))'))->await(), QuickJSTimeoutException::class, 'unresolved owner still times out while PHP is blocked'); +eq(42, $future->await(), 'worker callback completes independently of blocked PHP'); +eq(true, $blocked->eval('completed'), 'completed callback side effects are preserved'); +// Isolated entries still receive external callbacks, but pending callback +// Promises must release their Persistent handles before their runtime is freed. +$isolated = new QuickJS(isolated: true, timeoutMs: 1000); +$isolated->register('later', static function ($resolve): void { + Revolt\EventLoop::delay(0.001, static fn() => $resolve(42)); +}); +eq(42, Amp\async(fn() => $isolated->eval('new Promise(resolve => php.later(resolve))'))->await(), 'isolated eval awaits external resolution'); +$isolated->register('startPending', static function ($callback, $resolve) use (&$future): void { + $future = Amp\async(static fn() => $callback()); + Revolt\EventLoop::defer(static fn() => $resolve(8)); +}); +eq(8, $isolated->eval('new Promise(resolve => php.startPending(() => new Promise(() => {}), resolve))'), 'isolated owner finishes with a callback Promise pending'); +throws(fn() => $future->await(), Exception::class, 'isolated entry cancels and releases pending callback'); +eq(42, $isolated->eval('(async () => { await 0; return 42; })()'), 'new isolated runtime works after pending callback cleanup'); +// Polling a Promise future may itself enter PHP through an overridden then(). +$js->register('futureReentry', static fn(callable $callback) => $callback()); +eq(42, $js->eval('(() => { + const pending = new Promise(resolve => Promise.resolve().then(() => resolve(42))); + const then = pending.then; + pending.then = function (...args) { + php.futureReentry(async () => { await 0; return 7; }); + return then.apply(this, args); + }; + return pending; +})()', false), 'Promise future polling permits nested asynchronous PHP callbacks'); + +// A pending Promise and a posted call must not interfere with synchronous reentry. +$mixed = new QuickJS(timeoutMs: 1000); +$mixed->register('map', static fn(array $values, callable $callback) => array_map($callback, $values)); +$wait = $mixed->eval('() => new Promise(resolve => { globalThis.finishMixed = resolve; })', false); +$pushMixed = $mixed->eval('async value => { await 0; quickjs.postMessage(value); }', false); +$runMixed = $mixed->eval('() => php.map([1, 2, 3], n => php.map([n, n + 1], m => m * 2))', false); +$finishMixed = $mixed->eval('() => finishMixed(43)', false); +$pendingMixed = Amp\async(static fn() => $wait()); +Amp\delay(.001); +$pushMixed->post(7); +eq([[2, 4], [4, 6], [6, 8]], $runMixed(), 'nested synchronous callbacks coexist with pending and posted calls'); +eq([7], $mixed->awaitMessages(), 'posted Promise delivers its message during mixed calls'); +$finishMixed(); +eq(43, $pendingMixed->await(), 'unrelated pending Promise completes after nested callbacks'); +$mixed->close(); +done(); diff --git a/tests/php/15_timeout.php b/tests/php/15_timeout.php new file mode 100644 index 0000000..6a69df4 --- /dev/null +++ b/tests/php/15_timeout.php @@ -0,0 +1,8 @@ + $js->eval('while (true) {}'), QuickJSTimeoutException::class, 'existing wall-clock timeout interrupts JS'); +eq(3, $js->eval('1 + 2'), 'engine recovers after timeout'); +eq(4, (new QuickJS())->eval('Promise.resolve(4)'), 'Promise result is awaited without a timeout'); +done(); diff --git a/tests/php/16_async_messages.php b/tests/php/16_async_messages.php new file mode 100644 index 0000000..91899db --- /dev/null +++ b/tests/php/16_async_messages.php @@ -0,0 +1,25 @@ +eval('globalThis.value = { nested: { x: 1 }, bytes: new Uint8Array([0, 255]) }; quickjs.postMessage(value); value.nested.x = 9; value.bytes[0] = 42'); +$messages = $js->drainMessages(); +eq(1, $messages[0]['nested']['x'], 'postMessage snapshots nested data'); +eq("\0\xff", $messages[0]['bytes'], 'postMessage snapshots binary data'); +eq([], $js->drainMessages(), 'drain clears the queue'); + +$js->eval('quickjs.postMessage(new Uint8Array(17 * 1024 * 1024))'); +eq(17 * 1024 * 1024, strlen($js->drainMessages()[0]), 'message may exceed the old 16 MiB single-value cap'); +throws(fn() => new QuickJS(maxQueuedMessageBytes: 0), Throwable::class, 'queue budget must be positive'); + +$js->eval('try { quickjs.postMessage(new Uint8Array(33554433)); } catch (error) { quickjs.postMessage(error.message); }'); +$messages = $js->drainMessages(); +ok(str_contains($messages[0], 'size limit'), 'oversized result rejects only that operation'); + +$js = new QuickJS(maxQueuedMessageBytes: 256); +$js->eval('quickjs.postMessage(1)'); +throws(fn() => $js->eval('quickjs.postMessage(2)'), QuickJSEvalException::class, 'configured queue is bounded'); +eq([1], $js->drainMessages(), 'queue survives rejection'); +$js->eval('quickjs.postMessage(3)'); +eq([3], $js->drainMessages(), 'queue recovers after drain'); +done(); diff --git a/tests/php/17_stub_signatures.php b/tests/php/17_stub_signatures.php new file mode 100644 index 0000000..2053b90 --- /dev/null +++ b/tests/php/17_stub_signatures.php @@ -0,0 +1,59 @@ +getParameters() as $parameter) { + $parameters[] = [ + $parameter->getName(), + (string) ($parameter->getType() ?? 'mixed'), + $parameter->isPassedByReference(), + $parameter->isVariadic(), + $parameter->isOptional(), + $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null, + ]; + } + return [$method->isStatic(), $parameters, (string) ($method->getReturnType() ?? 'mixed')]; +} + +foreach (['QuickJS', 'Js\\Callback', 'QuickJSException', 'QuickJSEvalException', 'QuickJSTimeoutException', 'QuickJSMemoryException'] as $class) { + $native = new ReflectionClass($class); + $stub = new ReflectionClass('StubSignatures\\' . $class); + $nativeMethods = []; + $stubMethods = []; + foreach ($native->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getDeclaringClass()->getName() === $class) { + $nativeMethods[] = $method->getName(); + } + } + foreach ($stub->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getDeclaringClass()->getName() !== $stub->getName()) { + continue; + } + $stubMethods[] = $method->getName(); + ok($native->hasMethod($method->getName()), "$class::{$method->getName()} exists"); + if ($native->hasMethod($method->getName())) { + eq(signature($method), signature($native->getMethod($method->getName())), + "$class::{$method->getName()} matches canonical stub"); + } + } + sort($nativeMethods); + sort($stubMethods); + eq($stubMethods, $nativeMethods, "$class public methods match canonical stub"); +} + +done(); diff --git a/tests/php/18_javascript.php b/tests/php/18_javascript.php new file mode 100644 index 0000000..2e9e03f --- /dev/null +++ b/tests/php/18_javascript.php @@ -0,0 +1,29 @@ +eval('const answer: number = 42; answer'), 'TypeScript remains the default'); + eq(42, $js->eval('41 + 1', typescript: false), 'native JavaScript result'); + eq(42, $js->eval('(async () => { await 0; return 42; })()', false), 'native JavaScript awaits Promises'); + $js->register('apply', fn($callback) => $callback(6)); + eq(42, $js->eval('php.apply(n => n * 7)', false), 'native JavaScript shares the PHP bridge'); + $js->eval('quickjs.postMessage({ answer: 42 }); void 0', false); + eq([['answer' => 42]], $js->drainMessages(), 'native JavaScript shares the message queue'); + try { + $js->eval("\n\nthrow new TypeError('native boom')", false); + ok(false, 'native JavaScript error should throw'); + } catch (QuickJSEvalException $e) { + eq('TypeError', $e->getJsName(), 'native JS exception retains its type'); + eq('guest.js', $e->getFile(), 'native JS error has the original filename'); + eq(3, $e->getLine(), 'native JS error keeps its original line'); + ok(str_contains($e->getJsStack(), 'guest.js:3:'), 'native JS stack retains its original coordinates'); + } + throws(fn() => $js->eval('const n: number = 42;', false), QuickJSEvalException::class, 'native JS rejects TypeScript'); + throws(fn() => $js->eval('while (true) {}', false), QuickJSTimeoutException::class, 'native JS respects timeout'); + eq(42, $js->eval('42', false), 'native JS engine recovers after timeout'); + $limited = new QuickJS(isolated: $isolated, memoryLimit: 2 * 1024 * 1024); + throws(fn() => $limited->eval('let data = []; while (true) data.push(new Array(100000).fill(0));', false), QuickJSMemoryException::class, 'native JS respects heap limit'); +} + +done(); diff --git a/tests/php/19_error_paths.php b/tests/php/19_error_paths.php new file mode 100644 index 0000000..857f3e5 --- /dev/null +++ b/tests/php/19_error_paths.php @@ -0,0 +1,36 @@ +register('fail', fn() => throw new LogicException('host failure')); +$callback = $js->eval('() => php.fail()', false); +try { + $callback(); + ok(false, 'callback PHP exception must escape'); +} catch (LogicException $e) { + eq('host failure', $e->getMessage(), 'callback restores the original PHP class and message'); +} +eq(42, $js->eval('42', false), 'engine recovers after callback PHP exception'); + +$broken = $js->eval('() => { throw new RangeError("callback failure"); }', false); +throws(fn() => $broken(), QuickJSEvalException::class, 'callback JS error keeps its previous PHP type'); +$runaway = $js->eval('() => { while (true) {} }', false); +throws(fn() => $runaway(), QuickJSTimeoutException::class, 'callback timeout keeps its specialized type'); +eq(42, $js->eval('42', false), 'engine recovers after callback timeout'); + +$limited = new QuickJS(memoryLimit: 2 * 1024 * 1024); +$allocate = $limited->eval('() => { let data = []; while (true) data.push(new Array(100000).fill(0)); }', false); +throws(fn() => $allocate(), QuickJSEvalException::class, 'callback memory error retains its existing generic type'); +unset($allocate); +eq(42, $limited->eval('42', false), 'engine recovers after callback memory error'); + +$job = new QuickJS(timeoutMs: 20); +$job->eval('Promise.resolve().then(() => { while (true) {} }); void 0', false); +throws(fn() => $job->executePendingJobs(), QuickJSTimeoutException::class, 'job timeout keeps its specialized type'); +eq(42, $job->eval('42', false), 'engine recovers after job timeout'); + +$async = $js->eval('async () => { await 0; throw new TypeError("async callback failure"); }', false); +throws(fn() => $async(), QuickJSEvalException::class, 'async callback rejection is classified once'); +eq(42, $js->eval('42', false), 'engine recovers after async callback error'); + +done(); diff --git a/tests/php/20_cache_options.php b/tests/php/20_cache_options.php new file mode 100644 index 0000000..578f87c --- /dev/null +++ b/tests/php/20_cache_options.php @@ -0,0 +1,14 @@ + 0], ['transpileCacheMaxEntries' => 0], + ['transpileCacheMaxBytes' => 1, 'transpileCacheMaxEntries' => 1]] as $options) { + $js = new QuickJS(...['isolated' => $isolated, ...$options]); + eq(42, $js->eval('const answer: number = 42; answer;'), 'TS runs with configured cache'); + eq(43, $js->eval('43', typescript: false), 'direct JS runs with configured cache'); + } +} +throws(fn() => new QuickJS(transpileCacheMaxBytes: -1), Exception::class, 'negative byte budget rejected'); +throws(fn() => new QuickJS(transpileCacheMaxEntries: -1), Exception::class, 'negative entry limit rejected'); +done(); diff --git a/tests/php/21_worker.php b/tests/php/21_worker.php new file mode 100644 index 0000000..da01b73 --- /dev/null +++ b/tests/php/21_worker.php @@ -0,0 +1,180 @@ + new QuickJS(memoryLimit: 1), Throwable::class, 'failed initialization joins its worker and reports an error'); +$js = new QuickJS(timeoutMs: 2000); +$ticks = 0; +$timer = EventLoop::repeat(.002, static function () use (&$ticks): void { ++$ticks; }); +try { + eq(42, $js->eval('const end = Date.now()+100; while(Date.now()= 10, 'PHP timers run during CPU-bound JavaScript'); +} finally { EventLoop::cancel($timer); } +$js->register('echo', static fn($data) => $data); +eq("\xff\x00\xfe", $js->eval('php.echo(new Uint8Array([255,0,254]))', false), 'binary host callback stays typed'); +$js->register('close', static function () use ($js): void { $js->close(); }); +throws(fn() => $js->eval('php.close()', false), Throwable::class, 'close inside a host callback does not deadlock'); +$js->close(); +throws(fn() => $js->eval('42', false), Throwable::class, 'closed runtime rejects entries'); +throws(fn() => $js->register('x', fn()=>1), Throwable::class, 'closed runtime rejects registration'); +throws(fn() => $js->grant(new stdClass), Throwable::class, 'closed runtime rejects new handles'); + +$runtime = new QuickJS(); +$object = new stdClass(); +$weak = WeakReference::create($object); +$runtime->grant($object); +unset($object); +$runtime->close(); +eq(null, $weak->get(), 'close releases PHP handles'); + +$running = new QuickJS(); +$task = Amp\async(static fn() => $running->eval('while(true){}', false)); +Amp\delay(.02); +$start = hrtime(true); +$running->close(); +throws(fn() => $task->await(), Throwable::class, 'closing an executing runtime resumes its PHP caller'); +ok((hrtime(true)-$start)/1e6 < 1000, 'close interrupts a CPU loop promptly'); + +$budget = new QuickJS(timeoutMs: 20); +$budget->register('resolveThenBlock', static function ($resolve): void { + $resolve(42); + usleep(40000); +}); +throws(fn() => $budget->eval('new Promise(resolve => { Promise.resolve().then(() => php.resolveThenBlock(resolve)); })', false), QuickJSTimeoutException::class, 'resolving a Promise inside PHP does not disarm the active JS job deadline'); +eq(42, $budget->eval('42', false), 'runtime recovers after a resolved-but-blocked job timeout'); +$budget->close(); + +$independent = new QuickJS(timeoutMs: 100); +$waiting = $independent->eval('() => new Promise(() => {})', false); +$child = null; +$independent->register('start', static function ($resolve) use ($waiting, &$child): void { + $child = Amp\async(static fn() => $waiting()); + EventLoop::defer(static fn() => $resolve(1)); +}); +eq(1, $independent->eval('new Promise(resolve => php.start(resolve))', false), 'owner can finish before a callback Promise'); +Amp\delay(.07); +eq(42, $independent->eval('(() => {const end=Date.now()+50;while(Date.now() $child->await(), QuickJSTimeoutException::class, 'pending callback still observes its own timeout'); +$independent->close(); + +$reentrant = new QuickJS(); +$callable = new class($reentrant) { + public function __construct(private QuickJS $runtime) {} + public function __invoke(): int { return 1; } + public function __destruct() { $this->runtime->register('other', static fn() => 2); } +}; +$reentrant->register('value', $callable); +unset($callable); +$reentrant->register('value', static fn() => 3); +eq(5, $reentrant->eval('php.value()+php.other()', false), 'replacing capabilities releases PHP objects outside registry borrows'); +$resource = new class($reentrant) { + public function __construct(private QuickJS $runtime) {} + public function __destruct() { $this->runtime->grant('replacement'); } +}; +$handle = $reentrant->grant($resource); +unset($resource); +$reentrant->revoke($handle); +eq('replacement', $reentrant->resolve($handle+1), 'revoking handles permits PHP destructor reentry'); +$reentrant->close(); + +$marshal = new QuickJS(); +$called = false; +$value = $marshal->roundtrip(['then' => static function ($resolve) use (&$called): void { $called = true; $resolve(42); }]); +eq(true, is_array($value), 'roundtrip preserves records with a callable then property'); +eq(false, $called, 'roundtrip does not execute thenables'); +$marshal->close(); + +for($i=0;$i<20;$i++) { + $temporary = new QuickJS(); + $temporary->eval('42', false); + $temporary->close(); +} +eq([], EventLoop::getIdentifiers(), 'repeated create/close leaves no Revolt watchers'); +if (function_exists('pcntl_fork')) { + $parent = new QuickJS(); + $pid = pcntl_fork(); + if ($pid === 0) { + foreach ([fn() => $parent->eval('1', false), fn() => $parent->drainMessages(), fn() => $parent->close()] as $operation) { + try { $operation(); exit(2); } catch (Throwable $error) { + if (!str_contains($error->getMessage(), 'fork')) exit(3); + } + } + exit(0); + } + if ($pid < 0) throw new RuntimeException('fork failed'); + pcntl_waitpid($pid, $status); + eq(0, pcntl_wexitstatus($status), 'inherited runtimes fail clearly and child teardown does not deadlock'); + eq(42, $parent->eval('42', false), 'parent worker survives child exit'); + $parent->close(); +} +$messages = new QuickJS(); +$clock = $messages->eval('[quickjs.now(), quickjs.now()]', false); +ok($clock[0] >= 0 && $clock[1] >= $clock[0], 'native clock is monotonic'); +$batch = Amp\async(static fn() => $messages->awaitMessages()); +Amp\delay(.001); +throws(fn() => $messages->awaitMessages(), Throwable::class, 'message pump rejects multiple consumers'); +$messages->eval('Promise.resolve().then(() => quickjs.postMessage(42)); null', false); +eq([42], $batch->await(), 'message pump drives ready jobs without a JS drain callback'); +$batch = Amp\async(static fn() => $messages->awaitMessages()); +Amp\delay(.001); +$messages->close(); +throws(fn() => $batch->await(), Throwable::class, 'close resumes a message consumer'); + +$timedMessages = new QuickJS(timeoutMs: 20); +$batch = Amp\async(static fn() => $timedMessages->awaitMessages()); +Amp\delay(.001); +$timedMessages->eval('Promise.resolve().then(() => { while(true) {} }); null', false); +throws(fn() => $batch->await(), QuickJSTimeoutException::class, 'automatic job turns retain the configured execution budget'); +eq(42, $timedMessages->eval('42', false), 'runtime recovers after an automatic job timeout'); +$timedMessages->close(); + +$hostJobs = new QuickJS(); +$hostJobs->register('reenter', static function () use ($hostJobs) { + try { $hostJobs->eval('42', false); } + catch (Throwable $error) { return $error->getMessage(); } + return 'incorrectly accepted'; +}); +$batch = Amp\async(static fn() => $hostJobs->awaitMessages()); +Amp\delay(.001); +$hostJobs->eval('Promise.resolve().then(() => quickjs.postMessage(php.reenter())); null', false); +ok(str_contains($batch->await()[0], 'Cannot eval'), 'PHP host calls from automatic jobs retain the reentry guard'); +$hostJobs->close(); + +$posted = new QuickJS(); +$push = $posted->eval('value => { quickjs.postMessage(value); }', false); +for ($i = 0; $i < 512; ++$i) $push->post($i); +$values = []; +while (count($values) < 512) array_push($values, ...$posted->awaitMessages()); +eq(range(0, 511), $values, 'bounded posts preserve FIFO and make progress under admission pressure'); +$posted->close(); + +$postedError = new QuickJS(); +$explode = $postedError->eval('async () => { throw new Error("posted failure"); }', false); +$explode->post(); +try { $postedError->awaitMessages(); ok(false, 'posted errors must surface'); } +catch (Throwable $error) { ok(str_contains($error->getMessage(), 'posted failure'), 'posted rejection reaches the message consumer'); } +$postedError->close(); + +foreach (['throw', 'reenter'] as $cleanup) { + $closing = new QuickJS(); + $never = $closing->eval('() => new Promise(() => {})', false); + $resource = new class($closing, $cleanup) { + public function __construct(private QuickJS $js, private string $cleanup) {} + public function __destruct() { + if ($this->cleanup === 'reenter') $this->js->grant(new stdClass()); + throw new RuntimeException('cleanup failed'); + } + }; + $closing->grant($resource); + unset($resource); + $waiters = [Amp\async(static fn() => $never()), Amp\async(static fn() => $never())]; + Amp\delay(.005); + throws(fn() => $closing->close(), Throwable::class, "$cleanup during close propagates cleanup failure"); + Amp\delay(.001); + foreach ($waiters as $waiter) { + ok($waiter->isComplete(), "$cleanup during close resumes every waiter"); + throws(fn() => $waiter->await(), Throwable::class, "$cleanup during close rejects without deadlock"); + } +} +done(); diff --git a/tests/php/22_reentrant_rpc.php b/tests/php/22_reentrant_rpc.php new file mode 100644 index 0000000..0da9c02 --- /dev/null +++ b/tests/php/22_reentrant_rpc.php @@ -0,0 +1,131 @@ +register('map', static function (array $values, callable $callback) use (&$maps): array { + ++$maps; + return array_map($callback, $values); +}); +eq([2, 4, 6], $js->eval('php.map([1, 2, 3], n => n * 2)'), 'ordinary API supports synchronous nested callbacks'); +eq([[2, 4], [4, 6]], $js->eval('php.map([1, 2], n => php.map([n, n+1], m => m*2))'), 'nested PHP calls retain RPC semantics'); +eq(4, $maps, 'every PHP map is called exactly once'); +$js->register('identity', static fn($value) => $value); +eq([['text' => 'hello', 'bytes' => "\0\xff", 'flag' => true, 'nil' => null]], $js->eval('php.map([1], () => php.identity({text:"hello", bytes:new Uint8Array([0,255]), flag:true, nil:null}))'), 'nested RPC retains generic typed marshaling'); + +$js->register('recover', static function (callable $callback): string { + try { $callback(); } catch (Throwable $error) { return $error->getMessage(); } + return 'missing error'; +}); +eq('nested failure', $js->eval('php.recover(() => { throw new Error("nested failure"); })'), 'JS exceptions cross the RPC callback boundary'); +$js->register('throwPhp', static fn() => throw new LogicException('php failure')); +eq('LogicException:php failure', $js->eval('php.map([1], () => { try { php.throwPhp(); } catch(e) { return e.phpClass+":"+e.message; } })[0]'), 'nested PHP exceptions retain their class'); +eq(42, $js->eval('42'), 'worker resumes after nested exceptions'); + +$js->register('apply', static fn(callable $callback) => $callback()); +eq(42, $js->eval('php.apply(async () => { await 0; await 0; return 42; })'), 'nested Promise executes ready jobs'); +$js->register('later', static function (callable $resolve): void { + EventLoop::delay(.002, static fn() => $resolve(42)); +}); +eq(42, $js->eval('php.apply(() => new Promise(resolve => php.later(resolve)))'), 'nested Promise resumes through external I/O'); + +$js->register('sleep', static function (int $n): int { Amp\delay(.001); return $n * 2; }); +eq([2,4,6], Amp\async(static fn() => $js->eval('php.map([1,2,3], n => php.sleep(n))'))->await(), 'a nested PHP callback may suspend its Fiber'); + +$saved = $js->eval('n => php.map([n], m => m+1)[0]'); +$js->register('otherFiber', static function () use ($saved): int { + $future = Amp\async(static fn() => $saved(41)); + return $future->await(); +}); +eq(42, $js->eval('php.otherFiber()'), 'another Fiber can enter the exclusively borrowed session'); + +$post = $js->eval('value => quickjs.postMessage(value)'); +$consumer = Amp\async(static fn() => $js->awaitMessages()); +Amp\delay(.001); +$js->register('postAndWait', static function () use ($post, $consumer): int { + $post->post(42); + return $consumer->await()[0]; +}); +eq(42, $js->eval('php.postAndWait()'), 'posted calls wake a separate consumer before the PHP host call returns'); + +$sync = $js->eval('n => n'); +$push = $js->eval('n => quickjs.postMessage(n)'); +$js->register('finishQueued', static fn() => $sync(42)); +$busy = $js->eval('() => { const end=Date.now()+20; while(Date.now() $busy()); +Amp\delay(.001); +$push->post(7); +unset($push); +eq(42, $inFlight->await(), 'nested callbacks preserve earlier post admission'); +eq([7], $js->awaitMessages(), 'queued posts run before their deferred function deletion'); + +$emitBackground = $js->eval('value => quickjs.postMessage(value)'); +$backgroundConsumer = Amp\async(static fn() => $js->awaitMessages()); +Amp\delay(.001); +$backgroundDone = false; +$js->register('backgroundHost', static function () use ($emitBackground, $backgroundConsumer, &$backgroundDone): int { + $emitBackground->post(11); + $result = $backgroundConsumer->await()[0]; + $backgroundDone = true; + return $result; +}); +$js->eval('Promise.resolve().then(() => { globalThis.backgroundResult = php.backgroundHost(); }); null'); +eq([11], $backgroundConsumer->await(), 'background host calls can await an independent message consumer'); +while (!$backgroundDone) Amp\delay(.001); +eq(11, $js->eval('backgroundResult'), 'background host replies resume their suspended JS job'); + +$close = new QuickJS(); +$close->register('apply', static fn(callable $callback) => $callback()); +$close->register('close', static fn() => $close->close()); +throws(static fn() => $close->eval('php.apply(() => php.close())'), Throwable::class, 'close from a nested host call does not deadlock'); +$close->close(); + +$queued = new QuickJS(); +$called = false; +$queued->register('queued', static function () use (&$called): void { $called = true; }); +$queuedCall = $queued->eval('() => php.queued()'); +$queuedCall->post(); +usleep(5000); // Let the worker enqueue its host request without running Revolt. +$start = hrtime(true); +$queued->close(); +eq(false, $called, 'close discards an unconsumed host request'); +ok((hrtime(true)-$start)/1e6 < 1000, 'close joins a worker parked on an unconsumed host request'); + +for ($i = 0; $i < 20; ++$i) { + $abandoned = new QuickJS(); + $abandoned->register('noop', static fn() => null); + $abandonedCall = $abandoned->eval('() => php.noop()'); + $abandonedCall->post(); + unset($abandonedCall, $abandoned); +} +ok(true, 'destruction joins workers racing with unconsumed host requests'); + +$doomed = new QuickJS(); +$doomed->register('apply', static fn(callable $callback) => $callback()); +$paused = false; +$doomed->register('pause', static function () use (&$paused): void { + $paused = true; + Fiber::suspend(); +}); +$dying = new Fiber(static fn() => $doomed->eval('php.apply(() => php.pause())')); +$dying->start(); +Amp\delay(.01); +eq(true, $paused, 'nested host callback is suspended before Fiber destruction'); +unset($dying); +gc_collect_cycles(); +throws(static fn() => $doomed->eval('42'), Throwable::class, 'destroying a host Fiber closes the runtime without retaining its pending RPC'); +$doomed->close(); + +$budget = new QuickJS(timeoutMs: 20); +$budget->register('apply', static fn(callable $callback) => $callback()); +throws(static fn() => $budget->eval('php.apply(() => { while(true) {} })'), QuickJSTimeoutException::class, 'nested JS callbacks retain execution deadlines'); +eq(42, $budget->eval('42'), 'runtime recovers after nested callback timeout'); +throws(static fn() => $budget->eval('php.apply(() => new Promise(() => {}))'), QuickJSTimeoutException::class, 'nested external wait expires during a PHP host call'); +eq(42, $budget->eval('42'), 'runtime recovers after nested Promise timeout'); +$budget->close(); +$js->close(); +eq([], EventLoop::getIdentifiers(), 'nested RPC waits leave no watchers'); +done(); diff --git a/tests/php/23_post_progress.php b/tests/php/23_post_progress.php new file mode 100644 index 0000000..4a80a1c --- /dev/null +++ b/tests/php/23_post_progress.php @@ -0,0 +1,16 @@ +eval('value => quickjs.postMessage(value)', false); + for ($i = 0; $i < 512; ++$i) $push->post($i); + $values = []; + while (count($values) < 512) array_push($values, ...$js->awaitMessages()); + eq(range(0, 511), $values, "coalesced posts make progress and preserve FIFO ($trial)"); + $js->close(); +} +done(); diff --git a/tests/php/24_worker_progress.php b/tests/php/24_worker_progress.php new file mode 100644 index 0000000..f663a33 --- /dev/null +++ b/tests/php/24_worker_progress.php @@ -0,0 +1,21 @@ +eval('() => { Promise.resolve().then(() => quickjs.postMessage("worker progressed")); }', false); +$js->register('blockPhp', static function () use ($js, $kick): array { + $kick->post(); + // No event-loop turns or JS entries after post(): only the independent + // worker can execute the queued microtask while this PHP call is active. + for ($i = 0; $i < 200; ++$i) { + usleep(1000); + $messages = $js->drainMessages(); + if ($messages !== []) return $messages; + } + return []; +}); +eq(['worker progressed'], $js->eval('php.blockPhp()', false), 'worker executes JS jobs while its PHP host callback blocks'); +eq(42, $js->eval('42', false), 'runtime remains usable after concurrent PHP and JS progress'); +$js->close(); +done(); diff --git a/tests/php/25_event_driven.php b/tests/php/25_event_driven.php new file mode 100644 index 0000000..5d165a5 --- /dev/null +++ b/tests/php/25_event_driven.php @@ -0,0 +1,57 @@ +register('hold', static function ($resolve) use (&$callbacks, &$ready): void { + $callbacks[] = $resolve; + if (count($callbacks) === 16) $ready->complete(); +}); +$wait = $js->eval('() => new Promise(resolve => php.hold(resolve))', false); +try { + for ($round = 0; $round < 30; ++$round) { + $ready = new Amp\DeferredFuture(); + $tasks = []; + for ($i = 0; $i < 16; ++$i) $tasks[] = Amp\async(static fn() => $wait()); + $ready->getFuture()->await(new Amp\TimeoutCancellation(2)); + foreach ($callbacks as $callback) $callback(42); + $results = Amp\Future\await($tasks); + ksort($results); + eq(array_fill(0, 16, 42), $results, 'all concurrent host calls and Promise waiters progress'); + $callbacks = []; + } +} finally { + $js->close(); +} +eq([], Revolt\EventLoop::getIdentifiers(), 'concurrent RPC cleanup leaves no watchers'); +$shared = new QuickJS(); +$entered = 0; +$ready = new Amp\DeferredFuture(); +$shared->register('entered', static function () use (&$entered, $ready): void { + if (++$entered === 16) $ready->complete(); +}); +$shared->eval('globalThis.finish = null; globalThis.sharedPromise = new Promise(resolve => finish = resolve); null', false); +$wait = $shared->eval('() => { php.entered(); return sharedPromise; }', false); +$finish = $shared->eval('() => finish(42)', false); +$tasks = []; +try { + for ($i = 0; $i < 16; ++$i) $tasks[] = Amp\async(static fn() => $wait()); + $ready->getFuture()->await(new Amp\TimeoutCancellation(2)); + $finish(); + $results = Amp\Future\await($tasks); + ksort($results); + eq(array_fill(0, 16, 42), $results, 'one Promise can wake several independent requests'); +} finally { + $shared->close(); +} +$turns = new QuickJS(); +$dispatch = $turns->eval('globalThis.value = 0; start => { if (start) Promise.resolve().then(() => value = 42); else quickjs.postMessage(value); }', false); +$dispatch->post(true); +$dispatch->post(false); +eq([42], $turns->awaitMessages(), 'ready microtasks run before the next independent posted command'); +$turns->close(); +done();