diff --git a/Cargo.standalone.lock b/Cargo.standalone.lock index 5d890ae7..59d6dfc6 100644 --- a/Cargo.standalone.lock +++ b/Cargo.standalone.lock @@ -139,7 +139,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -162,9 +162,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.18.0" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -172,9 +172,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.44.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -236,7 +236,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools 0.13.0", @@ -258,9 +258,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -347,7 +347,7 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -382,9 +382,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.4.4" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "jobserver", @@ -403,9 +403,9 @@ 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" @@ -477,9 +477,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -487,9 +487,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.6" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -499,21 +499,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.4" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmake" @@ -545,7 +545,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -649,18 +649,18 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.135.1" +version = "0.135.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9903e767cc0a95a59950de099a6b1d61e1476f774be1cdfa06d7ccdc9bfae56f" +checksum = "9d620e7c8e86c48ff33f750fd49a2f3fa39529e6bfe59639dfdfcbf81329f737" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.135.1" +version = "0.135.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e777252ea3ec9daffa71408fbfe0793ab0a7b7bc3b2cf0f417dd0fa33964b8ef" +checksum = "75015effc364f66dfda9f45f3d78b5509957303258521143839adfd762eade60" dependencies = [ "cranelift-srcgen", ] @@ -713,9 +713,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.135.1" +version = "0.135.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d8ab23e63d78ea6bbda18ae1ed9c958bc1cb88d90fb5e95f5ba62e9c019474b" +checksum = "6672eac6d00c944a01712109d376019ab46ff4c3b37bb3419fa350b25523b1ad" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -725,15 +725,15 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.135.1" +version = "0.135.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24319a996b1ef40ebf8af69b39c868c790b35a42cfe38edacfd8c9deb75ea712" +checksum = "2f2bd3116e2cdef47a5755c484fa5feca18bd77ea7db418e492f72faaa7582f2" [[package]] name = "cranelift-control" -version = "0.135.1" +version = "0.135.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9856d68cc2cefb83229cb2c55b620dd38427555c2fe87d73f8a4421616f628" +checksum = "9706989f98e3f7f1b1bc16450089c0393b141f79701590f7bf830c3a531454a2" dependencies = [ "arbitrary", ] @@ -769,33 +769,33 @@ checksum = "f00258ff3d37f52ed731df679205e66175c728846dbde186ee20e93973d12ee7" [[package]] name = "cranelift-srcgen" -version = "0.135.1" +version = "0.135.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7431dd8c9def94aca43b27915dee0d42103d9b13922cfb044febcd975547d68" +checksum = "1f739117ccbeec70314433f304ced82abe89e75ff32ab676c2d7da661358af1e" [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -803,27 +803,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -1051,7 +1051,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -1147,7 +1147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1180,9 +1180,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fixedbitset" @@ -1305,7 +1305,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -1504,9 +1504,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -1555,9 +1555,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] @@ -1584,9 +1584,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1615,16 +1615,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -1794,9 +1795,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.1" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -1806,9 +1807,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "iprange" @@ -1919,9 +1920,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -2003,7 +2004,7 @@ checksum = "6f7edaa8ff7a14a1a9915d5ff85c65b24426d81edc0362635a687c70ce7c1ccc" dependencies = [ "anyhow", "atomic-take", - "bitflags 2.13.1", + "bitflags 2.13.2", "blake3", "bumpalo-herd", "bytesize", @@ -2138,9 +2139,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "lz4_flex" @@ -2164,18 +2165,15 @@ dependencies = [ [[package]] name = "mach2" -version = "0.4.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "mach2" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" +checksum = "b0d28e293f2b8c9d2b2d1c0193bd3c1cbdc0d2cf396888dc01162f7cf9d6c3f3" [[package]] name = "macho-unwind-info" @@ -2229,9 +2227,9 @@ dependencies = [ [[package]] name = "minicov" -version = "0.3.9" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" dependencies = [ "cc", "walkdir", @@ -2264,9 +2262,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -2323,7 +2321,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -2361,7 +2359,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2469,7 +2467,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cfg-if", "foreign-types", "libc", @@ -2581,7 +2579,7 @@ dependencies = [ "libc", "nix", "prost", - "rand 0.10.2", + "rand 0.10.3", "windows-sys 0.61.2", ] @@ -2804,7 +2802,7 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -2820,9 +2818,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -2840,15 +2838,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.17" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", @@ -2872,7 +2870,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2917,9 +2915,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -2992,7 +2990,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", ] [[package]] @@ -3012,7 +3010,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3060,14 +3058,14 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "region" -version = "4.0.0" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430988f137a23121407b362bf10b09df302ed5092ffeb9af90a549edcc849115" +checksum = "9bc10200af9f8297cb52df7e0e1db8d9ad3e8782981d69b25eedf858f5455ca2" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", - "mach2 0.4.3", - "windows-sys 0.61.2", + "mach2 0.7.0", + "windows-sys 0.52.0", ] [[package]] @@ -3087,11 +3085,11 @@ checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-core", @@ -3170,7 +3168,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3196,22 +3194,22 @@ dependencies = [ [[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 2.13.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -3247,9 +3245,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation", "core-foundation-sys", @@ -3263,14 +3261,14 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" @@ -3370,7 +3368,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3385,7 +3383,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation", "core-foundation-sys", "libc", @@ -3456,7 +3454,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3467,7 +3465,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3596,9 +3594,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[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 = "slab" @@ -3608,9 +3606,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "smoltcp" @@ -3693,7 +3691,7 @@ version = "13.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a213276280a1a737ff5fb89ae2a70ba51d1d4608e74736beb92f257f125f1aa" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "cpp_demangle 0.4.5", "itoa", "rustc-demangle", @@ -3724,9 +3722,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -3744,13 +3742,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 2.0.119", + "syn 3.0.6", ] [[package]] @@ -3777,10 +3775,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3790,7 +3788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3804,22 +3802,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3873,18 +3871,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" @@ -3910,7 +3899,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -3925,9 +3914,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -3960,9 +3949,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ "indexmap", "serde_core", @@ -3984,9 +3973,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.13+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ "indexmap", "toml_datetime", @@ -4030,7 +4019,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -4134,9 +4123,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-normalization" @@ -4232,9 +4221,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.26.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4441,9 +4430,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -4454,19 +4443,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4474,31 +4464,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-bindgen-test" -version = "0.3.77" +version = "0.3.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "895a2607575412a4eda1df892084a375ea10dfeadc4d7d2ab87b854e4ddc7ba1" +checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42" dependencies = [ "async-trait", "cast", @@ -4518,20 +4508,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-test-macro" -version = "0.3.77" +version = "0.3.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288cb0ebe215033bf949ae1fd046726daa4c32a157f24b9dc6ac387a52aa759" +checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] name = "wasm-bindgen-test-shared" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ff1c1b360982e93b6d8ea9c04836f71dba0817a16f91e229cf3a51bdd9d987" +checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" [[package]] name = "wasm-encoder" @@ -4554,12 +4544,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.258.0" +version = "0.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e974fe6821a8cf64575d51ea2194e2c8f77e7b66e9afe7419ce8a97f9ee0d251" +checksum = "b1d0246511d901aacf25d2dc9111f0054947de5e093fe662099e709ff7530dc3" dependencies = [ "leb128fmt", - "wasmparser 0.258.0", + "wasmparser 0.259.0", ] [[package]] @@ -4728,7 +4718,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] @@ -4767,6 +4757,7 @@ dependencies = [ "enum-iterator", "js-sys", "offset-allocator", + "parking_lot", "serde", "serde_json", "tar", @@ -4901,7 +4892,7 @@ dependencies = [ "petgraph", "pin-project", "pin-utils", - "rand 0.10.2", + "rand 0.10.3", "reqwest", "rkyv", "rusty_pool", @@ -4951,7 +4942,7 @@ version = "0.703.0" source = "git+https://github.com/wasmerio/wasmer.git?rev=c83ba6d315d051739339636da7080ed5987845de#c83ba6d315d051739339636da7080ed5987845de" dependencies = [ "anyhow", - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "num_enum", "serde", @@ -4973,7 +4964,7 @@ version = "0.255.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e329ef4b5d46e73b91d3ac6924417cad55a8cbbf869c199283383427c3320b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "hashbrown 0.17.1", "indexmap", ] @@ -4984,17 +4975,17 @@ version = "0.257.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d92fc335fb6d48f46bda1d8b26b69e28320c15ac3272208333833d6e217e2b4a" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "indexmap", ] [[package]] name = "wasmparser" -version = "0.258.0" +version = "0.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313" +checksum = "0f7c12eac7bb587801590f6a67ff0bc84d0748513864b71108e4b311cc3df694" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "indexmap", "semver", ] @@ -5011,31 +5002,31 @@ dependencies = [ [[package]] name = "wast" -version = "258.0.0" +version = "259.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97f7defc7ecca8b19ac7f824598eadd0c53985ee00c74060d65051e9da5b58a1" +checksum = "c69beba8d9da07af9a0971b559149a2ac4729895b55144de4fddbf5a02660648" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width", - "wasm-encoder 0.258.0", + "wasm-encoder 0.259.0", ] [[package]] name = "wat" -version = "1.258.0" +version = "1.259.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7555c008cca87f2ac58d9f83ccda7e7b44611093ce28eb28f052e7c78024b9bf" +checksum = "c6eec44b0c80391b20fb7ad9ea440c72c5d382385bdf74444bb18321c425017e" dependencies = [ "wast", ] [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -5121,7 +5112,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5307,9 +5298,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.18" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" [[package]] name = "xz" @@ -5342,30 +5333,30 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", @@ -5383,13 +5374,13 @@ 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 2.0.119", + "syn 3.0.6", "synstructure", ] @@ -5429,14 +5420,14 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.6", ] [[package]] name = "zlib-rs" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" @@ -5455,18 +5446,18 @@ dependencies = [ [[package]] name = "zstd-safe" -version = "7.2.4" +version = "7.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.standalone.toml b/Cargo.standalone.toml index dd2e4615..b3633eb0 100644 --- a/Cargo.standalone.toml +++ b/Cargo.standalone.toml @@ -6,6 +6,12 @@ edition = "2024" license = "MIT" resolver = "2" +[package.metadata.napi] +# The pinned public Wasmer SDK predates LinearMemory::do_wait_interruptible. +# build.rs uses this marker so the compatibility path cannot be changed by +# --no-default-features or --all-features. +standalone_legacy_wait = true + [features] js = [ "dep:js-sys", @@ -33,7 +39,9 @@ cli = [ anyhow = "1" # Standalone builds use one immutable Wasmer source revision. The N-API # instantiation hook and shared-memory bridge are developed together on the SDK -# branch, so mixing this source with crates.io lacks required APIs. +# branch, so mixing this source with crates.io lacks required APIs. This public +# revision also carries the APIs needed by the wasm32+js backend. Edge's WASIX +# child-reap integration uses its separately reviewed Wasmer source. wasmer = { git = "https://github.com/wasmerio/wasmer.git", rev = "c83ba6d315d051739339636da7080ed5987845de", default-features = false } # This dependencies are ONLY needed for testing napi in Wasm locally @@ -46,6 +54,7 @@ virtual-fs = { git = "https://github.com/wasmerio/wasmer.git", rev = "c83ba6d315 tokio = { version = "1", features = ["rt-multi-thread"], optional = true } enum-iterator = "2.3.0" offset-allocator = "0.2.0" +parking_lot = "0.12" [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = { version = "0.3", optional = true } diff --git a/Cargo.toml b/Cargo.toml index dcbfca29..f7b47dca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ virtual-fs = { version = "=0.704.0", path = "../virtual-fs", optional = true } tokio = { version = "1", features = ["rt-multi-thread"], optional = true } enum-iterator = "2.3.0" offset-allocator = "0.2.0" +parking_lot = "0.12" [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = { version = "0.3", optional = true } diff --git a/build.rs b/build.rs index 8f8a803e..bf4eb6c2 100644 --- a/build.rs +++ b/build.rs @@ -1,5 +1,5 @@ use std::{ - env, + env, fs, io::Read, path::{Path, PathBuf}, }; @@ -37,6 +37,12 @@ enum ExtraLink { } fn main() { + println!("cargo:rustc-check-cfg=cfg(napi_standalone_legacy_wait)"); + println!("cargo:rerun-if-changed=Cargo.toml"); + if standalone_legacy_wait() { + println!("cargo:rustc-cfg=napi_standalone_legacy_wait"); + } + let target_arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); if target_arch == "wasm32" { if env::var_os("CARGO_FEATURE_JS").is_none() { @@ -85,6 +91,24 @@ fn main() { let v8_defines = read_env_value("V8_DEFINES", &["NAPI_V8_DEFINES", "NAPI_V8_V8_DEFINES"]) .unwrap_or_else(|| "V8_COMPRESS_POINTERS".to_string()); + let sandbox_define = v8_defines + .split(&[';', ',', ' '][..]) + .any(|define| matches!(define, "V8_ENABLE_SANDBOX" | "V8_ENABLE_SANDBOX=1")); + if sandbox_define { + panic!( + "V8_ENABLE_SANDBOX is unsupported: N-API backing stores can reside outside the V8 sandbox" + ); + } + // The bridge exposes Wasmer linear memory and other embedder-owned + // allocations as V8 ArrayBuffer backing stores. V8's sandbox requires + // every such store to reside inside its sandbox address range. Until the + // bridge has a sandbox-resident backing-store implementation, accepting + // this archive would make ordinary guest code abort the host process. + if read_sandbox_build_flag(&v8.library_path) == Some(true) { + panic!( + "sandbox-enabled V8 archives are unsupported: N-API backing stores can reside outside the V8 sandbox" + ); + } let mut build = cc::Build::new(); build @@ -225,6 +249,49 @@ fn main() { } } +fn standalone_legacy_wait() -> bool { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is not set")); + let manifest = fs::read_to_string(manifest_dir.join("Cargo.toml")) + .expect("failed to read N-API Cargo.toml"); + let mut in_napi_metadata = false; + for line in manifest.lines() { + let line = line.trim(); + if line.starts_with('[') { + in_napi_metadata = line == "[package.metadata.napi]"; + } else if in_napi_metadata { + let declaration = line.split('#').next().unwrap_or("").trim(); + if let Some((key, value)) = declaration.split_once('=') + && key.trim() == "standalone_legacy_wait" + { + return value.trim() == "true"; + } + } + } + false +} + +fn read_sandbox_build_flag(library_path: &Path) -> Option { + let config_path = library_path.parent()?.parent()?.join("build-config.txt"); + println!("cargo:rerun-if-changed={}", config_path.display()); + let config = match std::fs::read_to_string(&config_path) { + Ok(config) => config, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None, + Err(err) => panic!("failed to read {}: {err}", config_path.display()), + }; + match config + .lines() + .find_map(|line| line.strip_prefix("v8_enable_sandbox=")) + { + Some("true") => Some(true), + Some("false") => Some(false), + _ => panic!( + "{} lacks a valid v8_enable_sandbox flag", + config_path.display() + ), + } +} + fn resolve_v8_config() -> Result { let include_override = read_env_value( "NAPI_V8_INCLUDE_DIR", diff --git a/cargo-standalone.sh b/cargo-standalone.sh index 7d073f2b..21541d23 100755 --- a/cargo-standalone.sh +++ b/cargo-standalone.sh @@ -71,4 +71,5 @@ for entry in build.rs include lib src tests v8; do ln -s "$repo_root/$entry" "$standalone_work_dir/$entry" done +export NAPI_SOURCE_ROOT="$repo_root" "${cargo_cmd[@]}" "$subcommand" --manifest-path "$standalone_work_dir/Cargo.toml" "$@" diff --git a/quickjs/src/internal/napi_module_wrap.cc b/quickjs/src/internal/napi_module_wrap.cc index f9c38bde..0c52796d 100644 --- a/quickjs/src/internal/napi_module_wrap.cc +++ b/quickjs/src/internal/napi_module_wrap.cc @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -217,6 +218,19 @@ void napi_module_wrap__::free_record(record *entry) if (entry == nullptr) return; + // Links store record pointers for duplicate-request checks. Clear incoming + // links before freeing a record so a parent cannot instantiate through a + // destroyed dependency or mistake a later allocation for the old record. + for (record *dependent : records_) + { + if (dependent == entry) + continue; + for (record *&linked : dependent->linked_records) + { + if (linked == entry) + linked = nullptr; + } + } remove(entry); for (auto &request : entry->requests) JS_FreeValue(ctx_, request.attributes); @@ -689,11 +703,44 @@ napi_status napi_module_wrap__::link(unofficial_napi_module module, return napi_ok; } +napi_status napi_module_wrap__::validate_linked_graph(record *root) +{ + // QuickJS can link implicitly while evaluating a module or a require() + // facade. Verify the whole reachable, not-yet-linked graph before any of + // those entry points follow request pointers. Keep already-linked modules + // usable after their wrapper handles have been destroyed. + std::unordered_set live(records_.begin(), records_.end()); + std::unordered_set visited; + std::vector pending{root}; + while (!pending.empty()) + { + record *entry = pending.back(); + pending.pop_back(); + if (!live.contains(entry)) + return throw_error("ERR_VM_MODULE_LINK_FAILURE", "Linked module is invalid"); + if (!visited.insert(entry).second || + JS_GetModuleStatus(ctx_, entry->module) != JS_MODULE_STATUS_UNLINKED) + continue; + if (entry->linked_records.size() != entry->requests.size()) + return throw_error("ERR_VM_MODULE_LINK_FAILURE", "Linked module is incomplete"); + for (record *linked : entry->linked_records) + { + if (!live.contains(linked)) + return throw_error("ERR_VM_MODULE_LINK_FAILURE", "Linked module is invalid"); + pending.push_back(linked); + } + } + return napi_ok; +} + napi_status napi_module_wrap__::instantiate(unofficial_napi_module module) { record *entry = find(module); if (entry == nullptr) return napi_util__::invalid_arg(env_); + napi_status validation = validate_linked_graph(entry); + if (validation != napi_ok) + return validation; if (JS_LinkModule(ctx_, entry->module) < 0) return return_pending_exception("Module linking failed"); return napi_ok; @@ -709,6 +756,9 @@ napi_status napi_module_wrap__::evaluate(unofficial_napi_module module, record *entry = find(module); if (entry == nullptr || result_out == nullptr) return napi_util__::invalid_arg(env_); + napi_status validation = validate_linked_graph(entry); + if (validation != napi_ok) + return validation; JSValue promise = JS_EvaluateModule(ctx_, entry->module); if (JS_IsException(promise)) return return_pending_exception("Failed to evaluate module"); @@ -725,6 +775,9 @@ napi_status napi_module_wrap__::evaluate_sync(unofficial_napi_module module, record *entry = find(module); if (entry == nullptr || result_out == nullptr) return napi_util__::invalid_arg(env_); + napi_status validation = validate_linked_graph(entry); + if (validation != napi_ok) + return validation; JSValue promise = JS_EvaluateModule(ctx_, entry->module); if (JS_IsException(promise)) @@ -1013,6 +1066,9 @@ napi_status napi_module_wrap__::create_required_module_facade(unofficial_napi_mo record *entry = find(module); if (entry == nullptr || result_out == nullptr) return napi_util__::invalid_arg(env_); + napi_status validation = validate_linked_graph(entry); + if (validation != napi_ok) + return validation; std::string filename = ""; diff --git a/quickjs/src/internal/napi_module_wrap.h b/quickjs/src/internal/napi_module_wrap.h index 81ebe5e5..1bffb2f5 100644 --- a/quickjs/src/internal/napi_module_wrap.h +++ b/quickjs/src/internal/napi_module_wrap.h @@ -104,6 +104,7 @@ class napi_module_wrap__ record *find_by_wrapper(JSValueConst wrapper) const; void remove(record *entry); void free_record(record *entry); + napi_status validate_linked_graph(record *root); JSValue get_host_defined_option_symbol() const; JSValue get_symbols_binding_property(const char *name) const; diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 4933b3ba..4fedb959 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,2 +1,2 @@ [toolchain] -channel = "1.95" +channel = "1.96" diff --git a/src/budget.rs b/src/budget.rs index babd280b..87fd1953 100644 --- a/src/budget.rs +++ b/src/budget.rs @@ -5,11 +5,10 @@ //! host-native V8. To keep an embedder-imposed memory budget honest across //! *both*, every pool charges against one shared [`ResourceBudget`] per app. //! -//! This module implements **Phase 1** of that design: the accountant itself -//! plus exact, deterministic charging of **guest wasm linear memory**. Later -//! phases charge the V8 heap, external memory, and host transients against the -//! same budget, and add CPU metering; the [`Pool`] enum and the budget API are -//! the insertion points for them. +//! The accountant reserves guest wasm linear memory, V8 heap ceilings, +//! declared external memory, and a bounded background lane against one shared +//! limit. These reservations enforce a limit; callers must not mistake them +//! for observed resident memory. The Edge task manager meters CPU separately. //! //! ## Native sys install path //! @@ -17,10 +16,10 @@ //! does not let an embedder inject a custom [`LinearMemory`] into a //! [`wasmer::Memory`] directly (the backend `VMMemory` enum is private), so the //! charge is installed one level down, via custom [`Tunables`]: a -//! [`BudgetedTunables`] wraps [`BaseTunables`] and returns every host memory +//! [`BudgetedTunables`] wraps [`BaseTunables`] and returns every native memory //! wrapped in a [`BudgetedMemory`]. Installing those tunables on the engine -//! (see `cli.rs`) makes `Memory::new` — and therefore the guest's imported -//! memory — budget-aware with no change to the memory-creation call site. +//! (see `cli.rs`) makes imported and module-defined memories budget-aware +//! with no change to the memory-creation call site. //! //! This tunables path exists only for Wasmer's native `sys` backend. A //! `wasm32` host-JavaScript build uses the host's WebAssembly memory and does @@ -28,6 +27,8 @@ //! [`ResourceBudget`] for provider-owned resources such as environments, //! value handles, and declared external memory. +#[cfg(not(target_arch = "wasm32"))] +use parking_lot::Mutex; use std::ffi::c_void; use std::sync::{ Arc, @@ -36,6 +37,8 @@ use std::sync::{ #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; +#[cfg(all(not(target_arch = "wasm32"), not(napi_standalone_legacy_wait)))] +use wasmer::sys::vm::StoreId; #[cfg(not(target_arch = "wasm32"))] use wasmer::sys::vm::{ ExpectedValue, LinearMemory, MemoryError, ThreadConditions, VMMemory, VMMemoryDefinition, @@ -63,13 +66,6 @@ pub const DEFAULT_UNWIND_SLACK: u64 = 16 * MIB; /// and V8's own malloc'd metadata without sampling. pub const DEFAULT_PER_ISOLATE_OVERHEAD: u64 = 8 * MIB; -/// Estimated host-side bytes held per N-API value handle crossed to the guest: -/// the `napi_ref` struct, the handle-map node, and a V8 global-handle slot. The -/// referenced JS value itself lives in the (separately charged) V8 heap. Used -/// only to derive a per-env cap on live handles — see -/// [`ResourceBudget::value_handle_limit`]. -pub const EST_HOST_BYTES_PER_VALUE: u64 = 128; - #[cfg(not(target_arch = "wasm32"))] fn pages_to_bytes(pages: Pages) -> u64 { u64::from(pages.0) * WASM_PAGE_SIZE as u64 @@ -83,8 +79,6 @@ fn round_up_to_page(bytes: u64) -> u64 { } /// A distinct byte pool metered against the budget. -/// -/// Later phases add external-memory and host-transient pools. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Pool { /// Guest wasm linear memory (wasmer `WasmMmap`). @@ -100,6 +94,18 @@ pub enum Pool { /// the only allocation path for them and they're charged as /// [`Pool::WasmLinear`] instead — see [`crate::guest_heap::GuestHeap`]. V8External, + /// Fixed reservation for one dedicated V8 background lane, including its + /// native thread stack and bounded pending task queue. + V8BackgroundLane, + /// Short-lived host snapshots of guest bytes and argument arrays. These + /// are charged before allocation and released with their owning buffer. + HostTransient, + /// Serialized worker messages retained between a sender and a receiver. + SerializedMessage, + /// Host-side bookkeeping the bridge keeps per guest handle (refs, value + /// slots, finalizer records, deferreds, scope frames, callback + /// registrations), granted in chunks by [`napi_host_bookkeeping_charge`]. + HostBookkeeping, } /// Embedder-owned aggregate accounting for byte reservations made by N-API. @@ -153,6 +159,12 @@ pub struct ResourceUsage { pub v8_heap_reserved: u64, /// Currently-charged V8 external memory (ArrayBuffer/Buffer) bytes. pub v8_external: u64, + pub v8_background_lane: u64, + /// Live bytes in host snapshots of guest data. + pub host_transient: u64, + pub serialized_message: u64, + /// Bytes granted to the bridge for per-handle host bookkeeping. + pub host_bookkeeping: u64, /// Number of live V8 isolates (envs) counted against `max_envs`. pub live_isolates: usize, } @@ -191,10 +203,10 @@ pub enum EnvRejected { /// One shared accountant per app, `Arc`-shared into every pool that allocates. /// -/// Charging is **reserve-based**: bytes are charged before (or exactly at) the -/// moment they become live, so `actual usage <= mem_charged <= mem_total` -/// always holds and enforcement never races a garbage collector. All state is -/// atomic, so worker threads (each its own store + isolate) share one budget. +/// Charging is **reserve-based** for the pools listed above. The charged total +/// is an admission limit for those pools, not an RSS measurement: some host +/// copies and V8 native allocations are outside these reservations. +/// All state is atomic so worker threads share one application budget. pub struct ResourceBudget { /// Total byte budget. `UNLIMITED` disables enforcement (tracking only). mem_total: u64, @@ -205,6 +217,10 @@ pub struct ResourceBudget { wasm_linear: AtomicU64, v8_heap_reserved: AtomicU64, v8_external: AtomicU64, + v8_background_lane: AtomicU64, + host_transient: AtomicU64, + serialized_message: AtomicU64, + host_bookkeeping: AtomicU64, /// Live V8 isolates (envs), counted against `max_envs`. live_isolates: AtomicUsize, } @@ -220,6 +236,22 @@ impl std::fmt::Debug for ResourceBudget { &self.v8_heap_reserved.load(Ordering::Acquire), ) .field("v8_external", &self.v8_external.load(Ordering::Acquire)) + .field( + "v8_background_lane", + &self.v8_background_lane.load(Ordering::Acquire), + ) + .field( + "host_transient", + &self.host_transient.load(Ordering::Acquire), + ) + .field( + "serialized_message", + &self.serialized_message.load(Ordering::Acquire), + ) + .field( + "host_bookkeeping", + &self.host_bookkeeping.load(Ordering::Acquire), + ) .field("live_isolates", &self.live_isolates.load(Ordering::Acquire)) .finish() } @@ -256,6 +288,10 @@ impl ResourceBudget { wasm_linear: AtomicU64::new(0), v8_heap_reserved: AtomicU64::new(0), v8_external: AtomicU64::new(0), + v8_background_lane: AtomicU64::new(0), + host_transient: AtomicU64::new(0), + serialized_message: AtomicU64::new(0), + host_bookkeeping: AtomicU64::new(0), live_isolates: AtomicUsize::new(0), }) } @@ -268,6 +304,10 @@ impl ResourceBudget { wasm_linear: AtomicU64::new(0), v8_heap_reserved: AtomicU64::new(0), v8_external: AtomicU64::new(0), + v8_background_lane: AtomicU64::new(0), + host_transient: AtomicU64::new(0), + serialized_message: AtomicU64::new(0), + host_bookkeeping: AtomicU64::new(0), live_isolates: AtomicUsize::new(0), } } @@ -368,6 +408,10 @@ impl ResourceBudget { Pool::WasmLinear => &self.wasm_linear, Pool::V8HeapReserved => &self.v8_heap_reserved, Pool::V8External => &self.v8_external, + Pool::V8BackgroundLane => &self.v8_background_lane, + Pool::HostTransient => &self.host_transient, + Pool::SerializedMessage => &self.serialized_message, + Pool::HostBookkeeping => &self.host_bookkeeping, } } @@ -379,6 +423,10 @@ impl ResourceBudget { wasm_linear: self.wasm_linear.load(Ordering::Acquire), v8_heap_reserved: self.v8_heap_reserved.load(Ordering::Acquire), v8_external: self.v8_external.load(Ordering::Acquire), + v8_background_lane: self.v8_background_lane.load(Ordering::Acquire), + host_transient: self.host_transient.load(Ordering::Acquire), + serialized_message: self.serialized_message.load(Ordering::Acquire), + host_bookkeeping: self.host_bookkeeping.load(Ordering::Acquire), live_isolates: self.live_isolates.load(Ordering::Acquire), } } @@ -388,18 +436,6 @@ impl ResourceBudget { self.live_isolates.load(Ordering::Acquire) } - /// Cap on the number of live per-value host handles an env may hold, derived - /// from the memory budget so this bookkeeping (which the byte pools don't - /// see) cannot grow the host RSS without bound. `None` under an unlimited - /// budget. - pub fn value_handle_limit(&self) -> Option { - if self.is_unlimited() { - None - } else { - Some((self.memory_limit() / EST_HOST_BYTES_PER_VALUE).max(1)) - } - } - /// Reserve budget for a new V8 env: acquire an isolate slot against /// `max_envs` and charge the (clamped) heap ceiling. On success the caller /// owns both and must release them with [`release_env`] exactly once — on @@ -515,10 +551,10 @@ impl ResourceBudget { } } -/// Per-V8-env heap-growth tracker shared with the host-owned near-heap-limit -/// callback. +/// Per-V8-env budget tracker shared with the host-owned near-heap-limit +/// callback and the bridge's bookkeeping hooks. /// -/// Boxed at env creation and handed to the callback as an opaque pointer; the +/// Boxed at env creation and handed to the bridge as an opaque pointer; the /// owning [`crate::env::NapiEnv`] reclaims it at env teardown to release the /// bytes granted beyond the initial ceiling. pub(crate) struct EnvHeapCharge { @@ -528,6 +564,72 @@ pub(crate) struct EnvHeapCharge { pub(crate) unwind_slack_available: AtomicBool, /// Bytes granted beyond the initial ceiling by grow-step grants. pub(crate) granted: AtomicU64, + /// Bytes granted to [`Pool::HostBookkeeping`] and not yet returned. + pub(crate) bookkeeping_granted: AtomicU64, +} + +impl EnvHeapCharge { + /// The budget refused: stop this env the way a host kill does. The sticky + /// flag keeps later imports out and termination unwinds any running JS. + fn deny(&self) { + self.host_stopped.store(true, Ordering::Release); + if self.env != 0 { + // SAFETY: the tracker is only reachable while its env is live. + unsafe { + crate::snapi::snapi_bridge_unofficial_terminate_execution( + self.env as crate::snapi::SnapiEnv, + ); + } + } + } +} + +/// Host-owned bookkeeping grant for a budgeted V8 isolate: the bridge asks for +/// `bytes` more of per-handle host bookkeeping. Returns nonzero when granted; +/// a denial stops the env exactly like an exhausted heap-growth grant. +/// +/// # Safety +/// As for [`napi_host_near_heap_limit_grant`]. +#[unsafe(no_mangle)] +pub extern "C" fn napi_host_bookkeeping_charge(data: *const c_void, bytes: u64) -> i32 { + if data.is_null() { + return 1; + } + // SAFETY: see the function's safety contract. + let tracker = unsafe { &*(data as *const EnvHeapCharge) }; + match tracker.budget.try_charge(Pool::HostBookkeeping, bytes) { + Ok(()) => { + tracker + .bookkeeping_granted + .fetch_add(bytes, Ordering::AcqRel); + 1 + } + Err(_) => { + tracker.deny(); + 0 + } + } +} + +/// Returns bookkeeping the bridge no longer needs, clamped to what it was +/// granted. The bridge serializes calls per env, so a plain load/store cannot +/// race. +/// +/// # Safety +/// As for [`napi_host_near_heap_limit_grant`]. +#[unsafe(no_mangle)] +pub extern "C" fn napi_host_bookkeeping_uncharge(data: *const c_void, bytes: u64) { + if data.is_null() { + return; + } + // SAFETY: see the function's safety contract. + let tracker = unsafe { &*(data as *const EnvHeapCharge) }; + let granted = tracker.bookkeeping_granted.load(Ordering::Acquire); + let release = bytes.min(granted); + tracker + .bookkeeping_granted + .store(granted - release, Ordering::Release); + tracker.budget.uncharge(Pool::HostBookkeeping, release); } /// Host-owned near-heap-limit callback for a budgeted V8 isolate. @@ -562,14 +664,7 @@ pub extern "C" fn napi_host_near_heap_limit_grant( current_limit.saturating_add(step as usize) } Err(_) => { - tracker.host_stopped.store(true, Ordering::Release); - if tracker.env != 0 { - unsafe { - crate::snapi::snapi_bridge_unofficial_terminate_execution( - tracker.env as crate::snapi::SnapiEnv, - ); - } - } + tracker.deny(); if tracker.unwind_slack_available.swap(false, Ordering::AcqRel) { current_limit.saturating_add(DEFAULT_UNWIND_SLACK as usize) } else { @@ -590,6 +685,9 @@ struct MemoryCharge { budget: Arc, /// Bytes currently charged for this allocation. bytes: AtomicU64, + /// Shared clones must serialize the full size/reserve/mutate/reconcile + /// sequence, not only the backend's growth operation. + operation: Mutex<()>, } #[cfg(not(target_arch = "wasm32"))] @@ -599,8 +697,15 @@ impl MemoryCharge { Ok(Arc::new(Self { budget, bytes: AtomicU64::new(bytes), + operation: Mutex::new(()), })) } + + fn release(&self, bytes: u64) { + let previous = self.bytes.fetch_sub(bytes, Ordering::AcqRel); + debug_assert!(previous >= bytes, "linear-memory charge underflow"); + self.budget.uncharge(Pool::WasmLinear, bytes); + } } #[cfg(not(target_arch = "wasm32"))] @@ -614,10 +719,12 @@ impl Drop for MemoryCharge { /// A [`LinearMemory`] that charges its bytes against a [`ResourceBudget`]. /// /// Delegates every operation to an inner backend `VMMemory`, except that growth -/// is charged first: `grow` (and `grow_at_least`) reserve the delta against the +/// is charged first: `grow` (and `grow_at_least`) reserve the new high-water /// budget and fail with [`MemoryError::CouldNotGrow`] — which the guest sees as /// `memory.grow` returning `-1`, i.e. an ordinary allocation failure — when the -/// budget is exhausted. The initial (minimum) size is charged at construction. +/// budget is exhausted. The initial (minimum) size is charged before backend +/// construction by [`BudgetedTunables`]. A reset retains the backing mapping, +/// so its high-water charge remains until the final handle drops. #[derive(Debug)] #[cfg(not(target_arch = "wasm32"))] pub struct BudgetedMemory { @@ -639,8 +746,16 @@ impl BudgetedMemory { Ok(Self { inner, charge }) } - fn budget(&self) -> &Arc { - &self.charge.budget + fn from_precharged(inner: VMMemory, charge: Arc) -> Result { + let actual = pages_to_bytes(inner.size()); + let reserved = charge.bytes.load(Ordering::Acquire); + if actual > reserved { + return Err(MemoryError::Generic(format!( + "linear memory initialized with {actual} bytes after reserving {reserved} bytes" + ))); + } + charge.release(reserved - actual); + Ok(Self { inner, charge }) } } @@ -664,73 +779,80 @@ impl LinearMemory for BudgetedMemory { } fn grow(&mut self, delta: Pages) -> Result { - let delta_bytes = pages_to_bytes(delta); - // Reserve first: a denied charge must look exactly like hitting the - // memory's maximum, so the guest's `memory.grow` returns -1. - self.budget() - .try_charge(Pool::WasmLinear, delta_bytes) - .map_err(|_| MemoryError::CouldNotGrow { - current: self.inner.size(), - attempted_delta: delta, - })?; - - match self.inner.grow(delta) { - Ok(previous) => { - self.charge.bytes.fetch_add(delta_bytes, Ordering::AcqRel); - Ok(previous) - } - Err(err) => { - // The real grow failed (e.g. hit its own maximum); give the - // reservation back so it does not leak against the budget. - self.budget().uncharge(Pool::WasmLinear, delta_bytes); - Err(err) + let charge = Arc::clone(&self.charge); + wasmer::sys::vm::on_host_stack(|| { + let _operation = charge.operation.lock(); + let before = pages_to_bytes(self.inner.size()); + let high_water = charge.bytes.load(Ordering::Acquire); + let target = before.saturating_add(pages_to_bytes(delta)); + let reserve = target.saturating_sub(high_water); + charge + .budget + .try_charge(Pool::WasmLinear, reserve) + .map_err(|_| MemoryError::CouldNotGrow { + current: self.inner.size(), + attempted_delta: delta, + })?; + match self.inner.grow(delta) { + Ok(previous) => { + let after = pages_to_bytes(self.inner.size()); + let actual = after.saturating_sub(high_water); + debug_assert!(actual <= reserve); + charge.bytes.fetch_add(actual, Ordering::AcqRel); + charge.budget.uncharge(Pool::WasmLinear, reserve - actual); + Ok(previous) + } + Err(err) => { + charge.budget.uncharge(Pool::WasmLinear, reserve); + Err(err) + } } - } + }) } fn grow_at_least(&mut self, min_size: u64) -> Result<(), MemoryError> { - let before = pages_to_bytes(self.inner.size()); - if min_size <= before { - // Already big enough; the inner call is a no-op that cannot grow. - return self.inner.grow_at_least(min_size); - } - - // Reserve an upper bound (page-rounded target minus current) up front, - // then reconcile to the size actually reached. - let reserve = round_up_to_page(min_size).saturating_sub(before); - self.budget() - .try_charge(Pool::WasmLinear, reserve) - .map_err(|_| MemoryError::CouldNotGrow { - current: self.inner.size(), - attempted_delta: Pages::from_bytes_rounded_up(min_size.saturating_sub(before)) - .unwrap_or(Pages(u32::MAX)), - })?; - - match self.inner.grow_at_least(min_size) { - Ok(()) => { - let actual = pages_to_bytes(self.inner.size()).saturating_sub(before); - if reserve > actual { - self.budget().uncharge(Pool::WasmLinear, reserve - actual); - } - self.charge.bytes.fetch_add(actual, Ordering::AcqRel); - Ok(()) + let charge = Arc::clone(&self.charge); + wasmer::sys::vm::on_host_stack(|| { + let _operation = charge.operation.lock(); + let before = pages_to_bytes(self.inner.size()); + if min_size <= before { + return self.inner.grow_at_least(min_size); } - Err(err) => { - self.budget().uncharge(Pool::WasmLinear, reserve); - Err(err) + let high_water = charge.bytes.load(Ordering::Acquire); + let reserve = round_up_to_page(min_size).saturating_sub(high_water); + charge + .budget + .try_charge(Pool::WasmLinear, reserve) + .map_err(|_| MemoryError::CouldNotGrow { + current: self.inner.size(), + attempted_delta: Pages::from_bytes_rounded_up(min_size.saturating_sub(before)) + .unwrap_or(Pages(u32::MAX)), + })?; + match self.inner.grow_at_least(min_size) { + Ok(()) => { + let after = pages_to_bytes(self.inner.size()); + let actual = after.saturating_sub(high_water); + debug_assert!(actual <= reserve); + charge.bytes.fetch_add(actual, Ordering::AcqRel); + charge.budget.uncharge(Pool::WasmLinear, reserve - actual); + Ok(()) + } + Err(err) => { + charge.budget.uncharge(Pool::WasmLinear, reserve); + Err(err) + } } - } + }) } fn reset(&mut self) -> Result<(), MemoryError> { - self.inner.reset()?; - // reset only ever shrinks; release the freed bytes. - let after = pages_to_bytes(self.inner.size()); - let previous = self.charge.bytes.swap(after, Ordering::AcqRel); - if previous > after { - self.budget().uncharge(Pool::WasmLinear, previous - after); - } - Ok(()) + let charge = Arc::clone(&self.charge); + wasmer::sys::vm::on_host_stack(|| { + let _operation = charge.operation.lock(); + // Wasmer resets the logical size but retains accessible backing. + // The high-water charge stays until the final handle drops. + self.inner.reset() + }) } fn vmmemory(&self) -> std::ptr::NonNull { @@ -742,27 +864,55 @@ impl LinearMemory for BudgetedMemory { // shares the same charge: the bytes are counted once and released when // the last handle drops. // `VMMemory::try_clone` (inherent) already yields a `VMMemory`. - let inner = self.inner.try_clone()?; - Ok(Box::new(BudgetedMemory { - inner, - charge: Arc::clone(&self.charge), - })) + wasmer::sys::vm::on_host_stack(|| { + let _operation = self.charge.operation.lock(); + let inner = self.inner.try_clone()?; + Ok(Box::new(BudgetedMemory { + inner, + charge: Arc::clone(&self.charge), + }) + as Box) + }) } fn copy(&self) -> Result, MemoryError> { - // A copy is a genuinely new allocation, so it needs its own charge. - let forked = self.inner.copy()?; - let bytes = pages_to_bytes(forked.size()); - let charge = MemoryCharge::new(Arc::clone(self.budget()), bytes) - .map_err(over_budget_to_memory_error)?; - Ok(Box::new(BudgetedMemory { - inner: VMMemory::from(forked), - charge, - })) + wasmer::sys::vm::on_host_stack(|| { + let _operation = self.charge.operation.lock(); + // Copy retains the source mapping's accessible backing even after + // reset has lowered its logical size. Reserve that high-water + // allocation before the backend allocates the copy. + let bytes = self.charge.bytes.load(Ordering::Acquire); + let charge = MemoryCharge::new(Arc::clone(&self.charge.budget), bytes) + .map_err(over_budget_to_memory_error)?; + let forked = self.inner.copy()?; + if pages_to_bytes(forked.size()) > bytes { + return Err(MemoryError::Generic( + "copied linear memory exceeded its quota reservation".into(), + )); + } + Ok(Box::new(BudgetedMemory { + inner: VMMemory::from(forked), + charge, + }) + as Box) + }) } fn as_shared(&self) -> Result { - self.inner.as_shared() + // The pinned public Wasmer used by the standalone CLI still detaches + // shared memories through this raw VMSharedMemory API. Its WASIX + // pthreads need that detach path for libuv's async workers. A raw + // handle drops our charge wrapper, so only permit it when accounting + // is explicitly unlimited. Managed Edge uses a newer Wasmer API that + // preserves the wrapper and never takes this compatibility path. + #[cfg(napi_standalone_legacy_wait)] + if self.charge.budget.accountant.is_none() && self.charge.budget.mem_total == UNLIMITED { + return self.inner.as_shared(); + } + + Err(MemoryError::UnsupportedOperation { + message: "budgeted memory requires wrapper-preserving shared detachment".into(), + }) } unsafe fn do_wait( @@ -773,11 +923,27 @@ impl LinearMemory for BudgetedMemory { ) -> Result { // SAFETY: forwarded verbatim to the inner memory, whose contract we // inherit; `dst` validity/alignment is the caller's responsibility. - unsafe { self.inner.do_wait(dst, expected, timeout) } + wasmer::sys::vm::on_host_stack(|| unsafe { self.inner.do_wait(dst, expected, timeout) }) + } + + #[cfg(not(napi_standalone_legacy_wait))] + unsafe fn do_wait_interruptible( + &mut self, + dst: u32, + expected: ExpectedValue, + timeout: Option, + store_id: StoreId, + ) -> Result { + // Preserve the store identity so force-stop can wake an infinite + // atomic.wait registered by the underlying shared memory. + wasmer::sys::vm::on_host_stack(|| unsafe { + self.inner + .do_wait_interruptible(dst, expected, timeout, store_id) + }) } fn do_notify(&mut self, dst: u32, count: u32) -> u32 { - self.inner.do_notify(dst, count) + wasmer::sys::vm::on_host_stack(|| self.inner.do_notify(dst, count)) } fn thread_conditions(&self) -> Option<&ThreadConditions> { @@ -785,15 +951,13 @@ impl LinearMemory for BudgetedMemory { } } -/// [`Tunables`] that wrap every host memory in a [`BudgetedMemory`] and clamp a +/// [`Tunables`] that wrap every native memory in a [`BudgetedMemory`] and clamp a /// requested memory's maximum to what the budget could ever grant. /// -/// All other logic delegates to the wrapped base tunables — mirroring the -/// `tunables_limit_memory` example. Module-*defined* memories (created via -/// `create_vm_memory` into a fixed VM slot) are left to the base tunables in -/// Phase 1; the edgejs guest *imports* its memory, so it flows through -/// `create_host_memory` and is charged. The max-pages clamp still bounds the -/// defined case cheaply. +/// All other logic delegates to the wrapped base tunables. Both imported and +/// module-defined memories reserve their initial pages before the backend maps +/// them. The wrapped backend must initialize with at most the requested +/// minimum; a larger actual size is rejected and the memory is dropped. #[cfg(not(target_arch = "wasm32"))] pub struct BudgetedTunables { base: T, @@ -859,8 +1023,11 @@ impl Tunables for BudgetedTunables { ) -> Result { let adjusted = self.adjust_memory(ty); self.validate_memory(&adjusted)?; + let reserved = pages_to_bytes(adjusted.minimum); + let charge = MemoryCharge::new(Arc::clone(&self.budget), reserved) + .map_err(over_budget_to_memory_error)?; let inner = self.base.create_host_memory(&adjusted, style)?; - let budgeted = BudgetedMemory::new(inner, Arc::clone(&self.budget))?; + let budgeted = BudgetedMemory::from_precharged(inner, charge)?; Ok(VMMemory::from( Box::new(budgeted) as Box )) @@ -874,12 +1041,19 @@ impl Tunables for BudgetedTunables { ) -> Result { let adjusted = self.adjust_memory(ty); self.validate_memory(&adjusted)?; + let reserved = pages_to_bytes(adjusted.minimum); + let charge = MemoryCharge::new(Arc::clone(&self.budget), reserved) + .map_err(over_budget_to_memory_error)?; // SAFETY: contract forwarded to base; `vm_definition_location` validity // is the caller's responsibility. - unsafe { + let inner = unsafe { self.base .create_vm_memory(&adjusted, style, vm_definition_location) - } + }?; + let budgeted = BudgetedMemory::from_precharged(inner, charge)?; + Ok(VMMemory::from( + Box::new(budgeted) as Box + )) } fn create_host_table(&self, ty: &TableType, style: &TableStyle) -> Result { @@ -906,11 +1080,81 @@ pub fn budgeted_tunables(budget: Arc) -> BudgetedTunables, + expected_store: StoreId, + } + + #[cfg(not(napi_standalone_legacy_wait))] + impl LinearMemory for InterruptWaitProbe { + fn ty(&self) -> MemoryType { + self.inner.ty() + } + + fn size(&self) -> Pages { + self.inner.size() + } + + fn style(&self) -> MemoryStyle { + self.inner.style() + } + + fn grow(&mut self, delta: Pages) -> Result { + self.inner.grow(delta) + } + + fn vmmemory(&self) -> std::ptr::NonNull { + self.inner.vmmemory() + } + + fn try_clone(&self) -> Result, MemoryError> { + Ok(Box::new(Self { + inner: self.inner.try_clone()?, + seen: Arc::clone(&self.seen), + expected_store: self.expected_store, + })) + } + + fn copy(&self) -> Result, MemoryError> { + Ok(Box::new(Self { + inner: VMMemory::from(self.inner.copy()?), + seen: Arc::clone(&self.seen), + expected_store: self.expected_store, + })) + } + + unsafe fn do_wait( + &mut self, + _dst: u32, + _expected: ExpectedValue, + _timeout: Option, + ) -> Result { + panic!("interruptible wait must not be downgraded to a plain wait") + } + + unsafe fn do_wait_interruptible( + &mut self, + _dst: u32, + _expected: ExpectedValue, + _timeout: Option, + store_id: StoreId, + ) -> Result { + assert_eq!(store_id, self.expected_store); + self.seen.store(true, Ordering::Release); + Ok(7) + } + } + /// A store whose engine charges guest wasm linear memory against `budget`. fn budgeted_store(budget: Arc) -> Store { let mut engine = EngineBuilder::new(Cranelift::default()).engine(); @@ -1033,6 +1277,123 @@ mod tests { assert_eq!(budget.memory_charged(), 6 * PAGE); } + #[test] + fn module_defined_memories_use_the_same_quota() { + let budget = ResourceBudget::with_memory_limit(2 * PAGE); + let mut store = budgeted_store(Arc::clone(&budget)); + let wasm = wat::parse_str(r#"(module (memory (export "memory") 1 2))"#).unwrap(); + let module = Module::new(&store, wasm).unwrap(); + let first = Instance::new(&mut store, &module, &Imports::new()).unwrap(); + let second = Instance::new(&mut store, &module, &Imports::new()).unwrap(); + assert_eq!(budget.snapshot().wasm_linear, 2 * PAGE); + let memory = first.exports.get_memory("memory").unwrap(); + assert!(memory.grow(&mut store, Pages(1)).is_err()); + assert!(Instance::new(&mut store, &module, &Imports::new()).is_err()); + assert_eq!(budget.snapshot().wasm_linear, 2 * PAGE); + drop((first, second, store)); + assert_eq!(budget.snapshot().wasm_linear, 0); + } + + // Public standalone Wasmer detaches copies through a raw shared-memory + // handle. Finite-budget copies fail closed there; this quota-preserving + // copy test applies to the newer wrapper-preserving managed Wasmer API. + #[cfg(not(napi_standalone_legacy_wait))] + #[test] + fn reset_keeps_backing_charged_and_copy_reserves_it() { + let budget = ResourceBudget::with_memory_limit(2 * PAGE); + let mut store = budgeted_store(Arc::clone(&budget)); + let memory = Memory::new(&mut store, MemoryType::new(1, Some(2), true)).unwrap(); + memory.reset(&mut store).unwrap(); + assert_eq!(memory.size(&store), Pages(0)); + assert_eq!(budget.snapshot().wasm_linear, PAGE); + + let copied = memory.copy(&store).unwrap(); + assert_eq!(budget.snapshot().wasm_linear, 2 * PAGE); + assert!(memory.copy(&store).is_err()); + assert_eq!(budget.snapshot().wasm_linear, 2 * PAGE); + drop(copied); + assert_eq!(budget.snapshot().wasm_linear, PAGE); + + memory.grow(&mut store, Pages(1)).unwrap(); + assert_eq!(budget.snapshot().wasm_linear, PAGE); + memory.grow_at_least(&mut store, 2 * PAGE).unwrap(); + assert_eq!(budget.snapshot().wasm_linear, 2 * PAGE); + drop(store); + assert_eq!(budget.snapshot().wasm_linear, 0); + } + + #[test] + fn direct_raw_shared_detachment_is_rejected() { + let budget = ResourceBudget::with_memory_limit(PAGE); + let ty = MemoryType::new(1, Some(1), true); + let base = BaseTunables::new(); + let style = base.memory_style(&ty); + let inner = base.create_host_memory(&ty, &style).unwrap(); + let wrapped = BudgetedMemory::new(inner, Arc::clone(&budget)).unwrap(); + assert!(LinearMemory::as_shared(&wrapped).is_err()); + assert_eq!(budget.snapshot().wasm_linear, PAGE); + drop(wrapped); + assert_eq!(budget.snapshot().wasm_linear, 0); + + #[cfg(napi_standalone_legacy_wait)] + { + let mut store = budgeted_store(Arc::clone(&budget)); + let memory = Memory::new(&mut store, ty).unwrap(); + assert!( + memory.as_shared(&store).is_none(), + "legacy Wasmer must reject raw detachment under a finite quota" + ); + } + } + + #[cfg(napi_standalone_legacy_wait)] + #[test] + fn standalone_unlimited_budget_keeps_wasix_shared_memory_detachable() { + let budget = ResourceBudget::unlimited(); + let mut store = budgeted_store(Arc::clone(&budget)); + let memory = Memory::new(&mut store, MemoryType::new(1, Some(2), true)).unwrap(); + let shared = memory + .as_shared(&store) + .expect("legacy Wasmer must be able to detach WASIX pthread memory"); + assert_eq!( + shared + .wait(MemoryLocation::new_32(0), Some(Duration::ZERO)) + .unwrap(), + 2, + ); + let mut worker_store = Store::new(store.engine().clone()); + let attached = shared.attach(&mut worker_store); + assert_eq!(attached.size(&worker_store), Pages(1)); + } + + #[cfg(not(napi_standalone_legacy_wait))] + #[test] + fn interruptible_wait_preserves_the_store_identity() { + let budget = ResourceBudget::with_memory_limit(PAGE); + let ty = MemoryType::new(1, Some(1), true); + let base = BaseTunables::new(); + let style = base.memory_style(&ty); + let inner = base.create_host_memory(&ty, &style).unwrap(); + let seen = Arc::new(AtomicBool::new(false)); + let store_id = StoreId::default(); + let probe = InterruptWaitProbe { + inner, + seen: Arc::clone(&seen), + expected_store: store_id, + }; + let mut memory = BudgetedMemory::new( + VMMemory::from(Box::new(probe) as Box), + budget, + ) + .unwrap(); + let result = unsafe { + LinearMemory::do_wait_interruptible(&mut memory, 0, ExpectedValue::None, None, store_id) + } + .unwrap(); + assert_eq!(result, 7); + assert!(seen.load(Ordering::Acquire)); + } + #[test] fn env_reservation_charges_ceiling_and_releases() { let budget = ResourceBudget::with_memory_limit(100 * MIB); @@ -1154,6 +1515,7 @@ mod tests { host_stopped: Arc::clone(&host_stopped), unwind_slack_available: AtomicBool::new(true), granted: AtomicU64::new(0), + bookkeeping_granted: AtomicU64::new(0), })); let data = ptr as *const c_void; let base = 100 * 1024 * 1024usize; @@ -1212,17 +1574,44 @@ mod tests { } #[test] - fn value_handle_limit_scales_with_budget() { - // Limit = budget / EST_HOST_BYTES_PER_VALUE. - let budget = ResourceBudget::with_memory_limit(1000 * EST_HOST_BYTES_PER_VALUE); - assert_eq!(budget.value_handle_limit(), Some(1000)); - // Unlimited budget imposes no cap. - assert_eq!(ResourceBudget::unlimited().value_handle_limit(), None); - // A tiny budget still allows at least one handle. - assert_eq!( - ResourceBudget::with_memory_limit(1).value_handle_limit(), - Some(1) + fn bookkeeping_grants_until_budget_exhausted_then_stops_the_env() { + let budget = ResourceBudget::with_memory_limit(3 * MIB); + let host_stopped = Arc::new(AtomicBool::new(false)); + let ptr = Box::into_raw(Box::new(EnvHeapCharge { + budget: Arc::clone(&budget), + env: 0, + host_stopped: Arc::clone(&host_stopped), + unwind_slack_available: AtomicBool::new(true), + granted: AtomicU64::new(0), + bookkeeping_granted: AtomicU64::new(0), + })); + let data = ptr as *const c_void; + + assert_eq!(napi_host_bookkeeping_charge(data, 2 * MIB), 1); + assert_eq!(budget.snapshot().host_bookkeeping, 2 * MIB); + assert!(!host_stopped.load(Ordering::Acquire)); + + // Over budget: denied, nothing charged, and the env is stopped. + assert_eq!(napi_host_bookkeeping_charge(data, 2 * MIB), 0); + assert_eq!(budget.snapshot().host_bookkeeping, 2 * MIB); + assert!(host_stopped.load(Ordering::Acquire)); + + // Returns are clamped to what was granted, so the pool cannot underflow. + napi_host_bookkeeping_uncharge(data, MIB); + napi_host_bookkeeping_uncharge(data, 10 * MIB); + assert_eq!(budget.snapshot().host_bookkeeping, 0); + + // Teardown releases whatever the bridge still held. + assert_eq!(napi_host_bookkeeping_charge(data, MIB), 1); + let tracker = unsafe { Box::from_raw(ptr) }; + budget.uncharge( + Pool::HostBookkeeping, + tracker.bookkeeping_granted.load(Ordering::Acquire), ); + assert_eq!(budget.snapshot().host_bookkeeping, 0); + + // A null tracker (unbudgeted env) always grants. + assert_eq!(napi_host_bookkeeping_charge(std::ptr::null(), MIB), 1); } #[test] @@ -1318,4 +1707,22 @@ mod external_accountant_tests { assert_eq!(accountant.memory_charged(), 40); assert_eq!(budget.snapshot().v8_heap_reserved, 0); } + + #[cfg(all(napi_standalone_legacy_wait, not(target_arch = "wasm32")))] + #[test] + fn external_accountant_cannot_use_legacy_raw_detachment() { + let accountant = TestAccountant::new(UNLIMITED); + let external: Arc = accountant.clone(); + let budget = ResourceBudget::with_accountant(external); + let ty = MemoryType::new(1, Some(1), true); + let base = BaseTunables::new(); + let inner = base + .create_host_memory(&ty, &base.memory_style(&ty)) + .unwrap(); + let wrapped = BudgetedMemory::new(inner, budget).unwrap(); + + assert!(LinearMemory::as_shared(&wrapped).is_err()); + drop(wrapped); + assert_eq!(accountant.memory_charged(), 0); + } } diff --git a/src/cli.rs b/src/cli.rs index 5f185545..a02ec2f0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -15,7 +15,8 @@ use wasmer_wasix::{ }; use crate::{ - NapiCtx, budget::ResourceBudget, budget::budgeted_tunables, guest::napi::register_env_imports, + NapiCtx, NapiRuntimeHooks, budget::ResourceBudget, budget::budgeted_tunables, + guest::napi::register_env_imports, }; use wasmer_c_api_imports::WasmCapiRuntimeHooks; @@ -165,13 +166,23 @@ pub fn run_wasix_main_capture_stdio_with_ctx( wasm_path: &Path, args: &[String], extra_mounts: &[GuestMount], +) -> Result<(i32, String, String)> { + run_wasix_main_capture_stdio_with_hooks(&ctx.runtime_hooks(), wasm_path, args, extra_mounts) +} + +/// Test and embedder entry point using hooks that initialize V8 lazily. +pub fn run_wasix_main_capture_stdio_with_hooks( + hooks: &NapiRuntimeHooks, + wasm_path: &Path, + args: &[String], + extra_mounts: &[GuestMount], ) -> Result<(i32, String, String)> { let (stdout_tx, stdout_rx) = Pipe::channel(); let (stderr_tx, stderr_rx) = Pipe::channel(); let stdout_thread = spawn_pipe_drain_thread(stdout_rx, Box::new(std::io::stdout())); let stderr_thread = spawn_pipe_drain_thread(stderr_rx, Box::new(std::io::stderr())); - let exit_code = run_wasix_main_with_runner( - ctx, + let exit_code = run_wasix_main_with_runner_hooks( + hooks, wasm_path, "guest-test", args, @@ -230,6 +241,26 @@ fn run_wasix_main_with_runner( extra_mounts: &[GuestMount], configure_runner: impl FnOnce(&mut WasiRunner), use_system_tty: bool, +) -> Result { + run_wasix_main_with_runner_hooks( + &ctx.runtime_hooks(), + wasm_path, + program_name, + args, + extra_mounts, + configure_runner, + use_system_tty, + ) +} + +fn run_wasix_main_with_runner_hooks( + hooks: &NapiRuntimeHooks, + wasm_path: &Path, + program_name: &str, + args: &[String], + extra_mounts: &[GuestMount], + configure_runner: impl FnOnce(&mut WasiRunner), + use_system_tty: bool, ) -> Result { let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -238,7 +269,7 @@ fn run_wasix_main_with_runner( let guard = runtime.enter(); let exit_code = { - let loaded = load_wasix_module_with_budget(wasm_path, ctx.budget())?; + let loaded = load_wasix_module_with_budget(wasm_path, hooks.budget())?; let engine = loaded.store.engine().clone(); let module = loaded.module; let module_hash = loaded.module_hash; @@ -261,7 +292,7 @@ fn run_wasix_main_with_runner( .enable_asynchronous_threading = false; } runtime - .with_instantiation_hook(ctx.runtime_hooks()) + .with_instantiation_hook(hooks.clone()) .with_instantiation_hook(WasmCapiRuntimeHooks::new()) .with_additional_imports(|_module, store| { let mut imports = wasmer::Imports::new(); diff --git a/src/ctx.rs b/src/ctx.rs index 8d25023d..69e5139b 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,4 +1,6 @@ use anyhow::{Context, Result, bail}; +#[cfg(not(all(target_arch = "wasm32", feature = "js")))] +use std::sync::atomic::AtomicU32; use std::{ collections::HashSet, sync::{ @@ -8,11 +10,14 @@ use std::{ }; use wasmer::{Extern, ExternType, FunctionEnv, Imports, Instance, Module, StoreMut, Table, Value}; +#[cfg(not(all(target_arch = "wasm32", feature = "js")))] +use crate::lane::ManagedV8LaneActivator; use crate::{ NAPI_EXTENSION_WASMER_MODULE_NAME, NAPI_EXTENSION_WASMER_MODULE_PREFIX, NAPI_MODULE_NAME, NapiEnv, NapiVersion, NapiWasmerExtensionVersion, budget::{NapiMemoryAccountant, ResourceBudget}, guest::napi::{is_known_napi_import, register_env_imports, register_napi_imports}, + message::PendingMessages, }; #[derive(Debug, Clone, Default)] @@ -43,7 +48,7 @@ pub struct NapiCtxBuilder { #[derive(Clone, Debug)] pub struct NapiCtx { - inner: Arc, + inner: Arc, } #[derive(Clone)] @@ -61,14 +66,13 @@ pub struct NapiInstantiationState { } /// Runtime hooks that provide N-API imports for WASIX guests. -// The import phase creates a per-instantiation session holding the function -// env backing the imported host functions, and hands it to the caller as -// opaque state. The hooks themselves are stateless, so concurrent -// instantiations — same module or not, same store or not — cannot receive -// each other's sessions. +// The import phase creates a lightweight per-instantiation binding for the +// WASM host functions. V8 and the dedicated background lane start only when a +// guest invokes an env-creation import. Each binding stays tied to its store; +// the shared state below owns only instance-wide accounting and stop control. #[derive(Clone, Debug)] pub struct NapiRuntimeHooks { - ctx: NapiCtx, + inner: Arc, } /// Opaque control surface for stopping every V8 isolate owned by a context. @@ -76,6 +80,7 @@ pub struct NapiRuntimeHooks { pub struct NapiRuntimeControl { envs: Arc>>, host_stopped: Arc, + pending_messages: Arc, } impl NapiRuntimeControl { @@ -100,23 +105,39 @@ impl NapiRuntimeControl { ); } } + drop(envs); + self.pending_messages.close_and_clear(); } } -#[derive(Debug)] -struct NapiCtxInner { +/// Lightweight import bindings. Managed embedders construct these while +/// linking a module; no NapiCtx, V8 isolate, or background queue exists yet. +struct NapiProviderBindings { limits: NapiLimits, - active_sessions: AtomicUsize, + active_sessions: Arc, + /// Guest-visible native env IDs span all worker sessions of this instance. + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + next_native_env_id: Arc, /// One shared accountant per app, `Arc`-shared into the engine's budgeted - /// tunables (guest wasm linear memory) and, in later phases, the V8 heap, - /// external-memory, and CPU paths. + /// tunables and the V8 heap, external-memory, and lane reservations. budget: Arc, + pending_messages: Arc, envs: Arc>>, host_stopped: Arc, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + managed_lane_activator: Option, +} + +impl std::fmt::Debug for NapiProviderBindings { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NapiProviderBindings") + .finish_non_exhaustive() + } } struct NapiSessionInner { - ctx: Arc, + ctx: Arc, + lease: Arc, imported_memory_type: Option, imported_table_type: Option, func_env: Mutex>>, @@ -128,9 +149,16 @@ impl std::fmt::Debug for NapiSession { } } -impl Drop for NapiSessionInner { +/// One admission slot shared by the temporary import session and every +/// FunctionEnv it installs. The slot stays occupied for the store lifetime, +/// including after `configure_instance` consumes its setup state. +pub(crate) struct SessionLease { + active_sessions: Arc, +} + +impl Drop for SessionLease { fn drop(&mut self) { - self.ctx.active_sessions.fetch_sub(1, Ordering::AcqRel); + self.active_sessions.fetch_sub(1, Ordering::AcqRel); } } @@ -168,7 +196,30 @@ impl NapiCtxBuilder { self } + /// Build lightweight import bindings for a managed embedder. The embedder + /// owns lazy instance activation and must return its managed V8 task queue + /// when a guest first creates an environment. Importing functions does not + /// invoke the callback or allocate a NapiCtx, V8 isolate, or queue. + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + pub fn build_managed_imports(self, activator: ManagedV8LaneActivator) -> NapiRuntimeHooks { + NapiRuntimeHooks { + inner: self.build_inner(Some(activator)), + } + } + pub fn build(self) -> NapiCtx { + NapiCtx { + inner: self.build_inner(None), + } + } + + fn build_inner( + self, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] managed_lane_activator: Option< + ManagedV8LaneActivator, + >, + #[cfg(all(target_arch = "wasm32", feature = "js"))] _background: Option<()>, + ) -> Arc { let budget = match self.accountant { Some(accountant) => ResourceBudget::with_accountant(accountant), None => match self.limits.memory_budget_bytes() { @@ -176,15 +227,20 @@ impl NapiCtxBuilder { None => ResourceBudget::unlimited(), }, }; - NapiCtx { - inner: Arc::new(NapiCtxInner { - limits: self.limits, - active_sessions: AtomicUsize::new(0), - budget, - envs: Arc::new(Mutex::new(HashSet::new())), - host_stopped: Arc::new(AtomicBool::new(false)), - }), - } + let envs = Arc::new(Mutex::new(HashSet::new())); + let host_stopped = Arc::new(AtomicBool::new(false)); + Arc::new(NapiProviderBindings { + limits: self.limits, + active_sessions: Arc::new(AtomicUsize::new(0)), + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + next_native_env_id: Arc::new(AtomicU32::new(1)), + budget, + pending_messages: PendingMessages::new(), + envs, + host_stopped, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + managed_lane_activator, + }) } } @@ -251,57 +307,86 @@ impl NapiCtx { } pub fn runtime_hooks(&self) -> NapiRuntimeHooks { - NapiRuntimeHooks { ctx: self.clone() } + NapiRuntimeHooks { + inner: Arc::clone(&self.inner), + } } pub fn runtime_control(&self) -> NapiRuntimeControl { - NapiRuntimeControl { - envs: Arc::clone(&self.inner.envs), - host_stopped: Arc::clone(&self.inner.host_stopped), - } + NapiRuntimeControl::from_inner(&self.inner) } pub fn new_session(&self, module: &Module) -> Result { - let previous = self.inner.active_sessions.fetch_add(1, Ordering::AcqRel); - if let Some(max_sessions) = self.inner.limits.max_sessions - && previous >= max_sessions + new_session(&self.inner, module) + } +} + +fn new_session(ctx: &Arc, module: &Module) -> Result { + let max_sessions = ctx.limits.max_sessions.unwrap_or(usize::MAX); + if ctx + .active_sessions + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + (current < max_sessions).then(|| current + 1) + }) + .is_err() + { + bail!("refusing to create more than {max_sessions} active N-API sessions"); + } + let lease = Arc::new(SessionLease { + active_sessions: Arc::clone(&ctx.active_sessions), + }); + + let imported_memory_type = module.imports().find_map(|import| { + if import.module() == "env" + && import.name() == "memory" + && let ExternType::Memory(ty) = import.ty() { - self.inner.active_sessions.fetch_sub(1, Ordering::AcqRel); - bail!("refusing to create more than {max_sessions} active N-API sessions"); + return Some(*ty); } + None + }); - let imported_memory_type = module.imports().find_map(|import| { - if import.module() == "env" - && import.name() == "memory" - && let ExternType::Memory(ty) = import.ty() - { - return Some(*ty); - } - None - }); + let imported_table_type = module.imports().find_map(|import| { + if import.module() == "env" + && import.name() == "__indirect_function_table" + && let ExternType::Table(ty) = import.ty() + { + return Some(*ty); + } + None + }); + + Ok(NapiSession { + inner: Arc::new(NapiSessionInner { + ctx: Arc::clone(ctx), + lease, + imported_memory_type, + imported_table_type, + func_env: Mutex::new(None), + }), + }) +} - let imported_table_type = module.imports().find_map(|import| { - if import.module() == "env" - && import.name() == "__indirect_function_table" - && let ExternType::Table(ty) = import.ty() - { - return Some(*ty); - } - None - }); - - Ok(NapiSession { - inner: Arc::new(NapiSessionInner { - ctx: Arc::clone(&self.inner), - imported_memory_type, - imported_table_type, - func_env: Mutex::new(None), - }), - }) +impl NapiRuntimeControl { + fn from_inner(inner: &Arc) -> Self { + Self { + envs: Arc::clone(&inner.envs), + host_stopped: Arc::clone(&inner.host_stopped), + pending_messages: Arc::clone(&inner.pending_messages), + } } } impl NapiRuntimeHooks { + /// Shared resource budget for the guest store and all V8 environments. + pub fn budget(&self) -> Arc { + Arc::clone(&self.inner.budget) + } + + pub fn runtime_control(&self) -> NapiRuntimeControl { + NapiRuntimeControl::from_inner(&self.inner) + } + /// Creates N-API imports when `module` requests them. pub fn additional_imports( &self, @@ -342,7 +427,7 @@ impl NapiRuntimeHooks { bail!("unsupported Wasmer N-API extension version: {version:?}"); } - let session = self.ctx.prepare_module(module)?; + let session = new_session(&self.inner, module)?; session.add_imports(store, imports)?; Ok(NapiInstantiationState { session: Some(session), @@ -388,9 +473,14 @@ impl NapiSession { let napi_env = NapiEnv::new( Arc::clone(&self.inner.ctx.budget), + Arc::clone(&self.inner.ctx.pending_messages), self.inner.ctx.limits.max_envs, Arc::clone(&self.inner.ctx.envs), Arc::clone(&self.inner.ctx.host_stopped), + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + Arc::clone(&self.inner.ctx.next_native_env_id), + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + self.inner.ctx.managed_lane_activator.clone(), ); let func_env = FunctionEnv::new(store, napi_env); { @@ -433,6 +523,11 @@ impl NapiSession { func_env.as_mut(&mut *store).table = Some(table); } + // Wasmer owns this FunctionEnv until its store is destroyed. Retain + // the admission slot with it; the setup session is consumed by + // configure_instance long before the guest module stops running. + func_env.as_mut(&mut *store).session_lease = Some(Arc::clone(&self.inner.lease)); + Ok(()) } @@ -453,34 +548,24 @@ impl NapiSession { .context("missing runtime function env during instance setup")? }; - if let Some(memory) = imported_memory { - func_env.as_mut(&mut *store).memory = Some(memory.clone()); + // Imports may already have bound a memory and a start section may + // already have created its guest heap from it. Never replace that + // memory with a different exported one in a multi-memory module. + // Otherwise a module-defined export can be bound after instantiation + // for a later first N-API call. Keep the guest heap lazy either way. + if func_env.as_ref(&*store).memory.is_none() { + let instance_memory = imported_memory + .cloned() + .or_else(|| instance.exports.get_memory("memory").ok().cloned()); + if let Some(memory) = instance_memory { + func_env.as_mut(&mut *store).memory = Some(memory); + } } - #[cfg(not(all(target_arch = "wasm32", feature = "js")))] - { - // Stand up the host-side guest-memory allocator now that the memory is - // final. All bridge allocations of guest memory go through it, AND it - // is the only backing store for V8 ArrayBuffers/Buffers/TypedArrays - // (see TrackingArrayBufferAllocator in unofficial_napi.cc) — the - // guest's own malloc is never called from the host, and host-heap - // allocation is never used for guest-visible V8 memory. A module that - // requests N-API imports without usable linear memory, or whose - // memory can't support a GuestHeap, cannot run under this bridge: we - // fail instantiation here rather than let allocation silently fall - // back to unbudgeted, non-guest-shared host memory later. - let memory = func_env - .as_ref(&*store) - .memory - .clone() - .context("N-API imports require the guest to have linear memory")?; - let budget = Arc::clone(&func_env.as_ref(&*store).budget); - let heap = crate::guest_heap::GuestHeap::get_or_create(&mut *store, &memory, budget) - .context( - "failed to initialize the guest-heap allocator over the guest's linear memory", - )?; - func_env.as_mut(&mut *store).guest_heap = Some(heap); - } + // Keep import-only modules cheap, including those with no linear + // memory. The first environment creation validates memory and installs + // the allocator after Edge has admitted the lane. A start section may + // already have installed its heap; never replace it here. // The browser backend cannot expose a native pointer into Wasmer's JS // Memory. Keep its established guest allocator: exported malloc owns @@ -523,6 +608,16 @@ fn napi_wasmer_extension_version_from_namespace( mod tests { use super::NapiCtx; use crate::{NapiVersion, NapiWasmerExtensionVersion}; + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + thread, + time::Duration, + }; use wasmer::{AsStoreMut, Instance, Module, Store}; use wat::parse_str; @@ -584,11 +679,601 @@ mod tests { assert_eq!(ctx.active_sessions(), 1); } + #[test] + fn configured_store_keeps_its_session_slot_until_drop() { + let mut first_store = Store::default(); + let mut second_store = Store::new(first_store.engine().clone()); + let module = compile_wat( + &first_store, + r#"(module + (import "napi" "napi_get_undefined" (func (param i32 i32) (result i32))) + (import "env" "memory" (memory 1)) + )"#, + ); + let ctx = NapiCtx::builder().max_sessions(1).build(); + let hooks = ctx.runtime_hooks(); + + let (imports, state) = hooks + .additional_imports(&module, &mut first_store.as_store_mut()) + .expect("first session should be admitted"); + let instance = Instance::new(&mut first_store, &module, &imports).unwrap(); + hooks + .configure_instance( + &module, + &mut first_store.as_store_mut(), + &instance, + None, + state, + ) + .unwrap(); + assert_eq!(ctx.active_sessions(), 1); + assert!( + hooks + .additional_imports(&module, &mut second_store.as_store_mut()) + .is_err(), + "a live store must retain its session slot" + ); + + drop(instance); + drop(imports); + drop(first_store); + assert_eq!(ctx.active_sessions(), 0); + let (_imports, _state) = hooks + .additional_imports(&module, &mut second_store.as_store_mut()) + .expect("dropping the first store releases the session slot"); + } + + #[test] + fn failed_import_setup_releases_its_session_slot() { + let mut store = Store::default(); + let module = compile_wat( + &store, + r#"(module + (import "napi" "napi_get_undefined" (func (param i32 i32) (result i32))) + (import "env" "memory" (memory 1)) + )"#, + ); + let ctx = NapiCtx::builder().max_sessions(1).build(); + let hooks = ctx.runtime_hooks(); + let mut invalid = wasmer::Imports::new(); + invalid.define( + "env", + "memory", + wasmer::Function::new_typed(&mut store, || 0_i32), + ); + assert!( + hooks + .add_imports(&module, &mut store.as_store_mut(), &mut invalid) + .is_err() + ); + assert_eq!(ctx.active_sessions(), 0); + + let mut valid = wasmer::Imports::new(); + let state = hooks + .add_imports(&module, &mut store.as_store_mut(), &mut valid) + .expect("a failed setup must not consume the live-session limit"); + drop(state); + assert_eq!(ctx.active_sessions(), 1); + drop(valid); + drop(store); + assert_eq!(ctx.active_sessions(), 0); + } + fn compile_wat(store: &Store, wat: &str) -> Module { let wasm = parse_str(wat).expect("wat module parses"); Module::new(store, wasm).expect("wat module compiles") } + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + fn managed_hooks( + spawns: Arc, + finished: mpsc::Sender<()>, + ) -> ( + super::NapiRuntimeHooks, + Arc>>>, + ) { + let slot = Arc::new(std::sync::Mutex::new(None)); + let activator = { + let slot = Arc::clone(&slot); + Arc::new(move || { + let mut guard = slot.lock().unwrap(); + if let Some(lane) = &*guard { + return Ok(Arc::clone(lane)); + } + let lane = + crate::ManagedV8Lane::new(256, Arc::new(|| Box::new(())), Arc::new(|| {}))?; + let worker_lane = Arc::clone(&lane); + let finished = finished.clone(); + thread::Builder::new() + .name("test-v8-lane".into()) + .spawn(move || { + worker_lane.run(); + let _ = finished.send(()); + })?; + spawns.fetch_add(1, Ordering::SeqCst); + *guard = Some(Arc::clone(&lane)); + Ok(lane) + }) + }; + (NapiCtx::builder().build_managed_imports(activator), slot) + } + + #[test] + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + fn lazy_hooks_start_lane_only_when_guest_calls_napi() { + let spawns = Arc::new(AtomicUsize::new(0)); + let (finished_tx, finished_rx) = mpsc::channel(); + let (hooks, lane_slot) = managed_hooks(Arc::clone(&spawns), finished_tx); + let mut store = Store::default(); + let import_only = compile_wat( + &store, + r#"(module + (import "napi" "napi_get_undefined" (func (param i32 i32) (result i32))) + (import "env" "memory" (memory 1 512)) + )"#, + ); + let (imports, state) = hooks + .additional_imports(&import_only, &mut store.as_store_mut()) + .unwrap(); + let memory = state + .session + .as_ref() + .unwrap() + .inner + .func_env + .lock() + .unwrap() + .as_ref() + .unwrap() + .as_ref(&store) + .memory + .clone() + .unwrap(); + let pages_before = memory.size(&store); + let charge_before = hooks.budget().snapshot().mem_charged; + let instance = Instance::new(&mut store, &import_only, &imports).unwrap(); + hooks + .configure_instance( + &import_only, + &mut store.as_store_mut(), + &instance, + None, + state, + ) + .unwrap(); + assert!(!lane_slot.lock().unwrap().is_some()); + assert_eq!(spawns.load(Ordering::SeqCst), 0); + assert_eq!(memory.size(&store), pages_before); + assert_eq!(hooks.budget().snapshot().mem_charged, charge_before); + + let invoked = compile_wat( + &store, + r#"(module + (import "napi" "napi_wasm_init_env" (func $init (result i32))) + (import "env" "memory" (memory 1 512)) + (func (export "invoke") (result i32) call $init) + )"#, + ); + let (imports, state) = hooks + .additional_imports(&invoked, &mut store.as_store_mut()) + .unwrap(); + let instance = Instance::new(&mut store, &invoked, &imports).unwrap(); + hooks + .configure_instance(&invoked, &mut store.as_store_mut(), &instance, None, state) + .unwrap(); + assert_eq!(spawns.load(Ordering::SeqCst), 0); + let invoke = instance + .exports + .get_typed_function::<(), i32>(&store, "invoke") + .unwrap(); + assert!(invoke.call(&mut store).unwrap() > 0); + assert!(lane_slot.lock().unwrap().is_some()); + assert_eq!(spawns.load(Ordering::SeqCst), 1); + lane_slot.lock().unwrap().as_ref().unwrap().stop(); + finished_rx + .recv_timeout(Duration::from_secs(5)) + .expect("lane stops"); + } + + #[test] + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + fn runtime_options_accept_only_default_flags_and_leave_lane_lazy() { + let spawns = Arc::new(AtomicUsize::new(0)); + let (finished_tx, _finished_rx) = mpsc::channel(); + let (hooks, lane_slot) = managed_hooks(Arc::clone(&spawns), finished_tx); + let mut store = Store::default(); + // Runtime options are `{ size, version, engine_flags, engine_flags_length }`. + let module = compile_wat( + &store, + r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_configure_runtime" + (func $configure (param i32) (result i32))) + (import "env" "memory" (memory 1 512)) + (data (i32.const 64) "--js-source-phase-imports --harmony-import-attributes") + (func $options (param $flags i32) (param $length i32) (result i32) + (i32.store (i32.const 16) (i32.const 16)) + (i32.store (i32.const 20) (i32.const 1)) + (i32.store (i32.const 24) (local.get $flags)) + (i32.store (i32.const 28) (local.get $length)) + (call $configure (i32.const 16))) + (func (export "no_options") (result i32) + i32.const 0 call $configure) + (func (export "no_flags") (result i32) + i32.const 0 i32.const 0 call $options) + (func (export "defaults") (result i32) + i32.const 64 i32.const 53 call $options) + (func (export "one_default") (result i32) + i32.const 64 i32.const 25 call $options) + (func (export "other_flags") (result i32) + i32.const 64 i32.const 8 call $options) + (func (export "too_long") (result i32) + i32.const 64 i32.const 65 call $options) + (func (export "out_of_bounds") (result i32) + i32.const 33554400 i32.const 53 call $options) + )"#, + ); + let (imports, state) = hooks + .additional_imports(&module, &mut store.as_store_mut()) + .unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + hooks + .configure_instance(&module, &mut store.as_store_mut(), &instance, None, state) + .unwrap(); + let charged = hooks.budget().snapshot().mem_charged; + let call = |store: &mut Store, name: &str| { + instance + .exports + .get_typed_function::<(), i32>(&*store, name) + .unwrap() + .call(store) + .unwrap() + }; + assert_eq!(call(&mut store, "no_options"), 0); + assert_eq!(call(&mut store, "no_flags"), 0); + assert_eq!(call(&mut store, "defaults"), 0); + assert_eq!(call(&mut store, "one_default"), 0); + assert_eq!(call(&mut store, "other_flags"), 1); + assert_eq!(call(&mut store, "too_long"), 1); + assert_eq!(call(&mut store, "out_of_bounds"), 1); + assert_eq!(spawns.load(Ordering::SeqCst), 0); + assert!(lane_slot.lock().unwrap().is_none()); + assert_eq!(hooks.budget().snapshot().mem_charged, charged); + } + + #[test] + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + fn memoryless_import_only_module_stays_inert() { + let spawns = Arc::new(AtomicUsize::new(0)); + let (finished_tx, _finished_rx) = mpsc::channel(); + let (hooks, lane_slot) = managed_hooks(Arc::clone(&spawns), finished_tx); + let mut store = Store::default(); + let module = compile_wat( + &store, + r#"(module + (import "napi" "napi_wasm_init_env" (func $init (result i32))) + (func (export "invoke") (result i32) call $init) + )"#, + ); + let (imports, state) = hooks + .additional_imports(&module, &mut store.as_store_mut()) + .unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + hooks + .configure_instance(&module, &mut store.as_store_mut(), &instance, None, state) + .unwrap(); + assert_eq!(spawns.load(Ordering::SeqCst), 0); + assert!(lane_slot.lock().unwrap().is_none()); + assert_eq!(hooks.budget().snapshot().mem_charged, 0); + + // Actual N-API use fails without guest memory before activation. + let invoke = instance + .exports + .get_typed_function::<(), i32>(&store, "invoke") + .unwrap(); + assert_eq!(invoke.call(&mut store).unwrap(), 0); + assert_eq!(spawns.load(Ordering::SeqCst), 0); + assert!(lane_slot.lock().unwrap().is_none()); + } + + #[test] + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + fn exported_memory_is_bound_without_initializing_napi() { + let spawns = Arc::new(AtomicUsize::new(0)); + let (finished_tx, finished_rx) = mpsc::channel(); + let (hooks, lane_slot) = managed_hooks(Arc::clone(&spawns), finished_tx); + let mut store = Store::default(); + let module = compile_wat( + &store, + r#"(module + (import "napi" "napi_wasm_init_env" (func $init (result i32))) + (memory (export "memory") 1 512) + (func (export "invoke") (result i32) call $init) + )"#, + ); + let (imports, state) = hooks + .additional_imports(&module, &mut store.as_store_mut()) + .unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + hooks + .configure_instance(&module, &mut store.as_store_mut(), &instance, None, state) + .unwrap(); + let memory = instance.exports.get_memory("memory").unwrap().clone(); + let pages_before = memory.size(&store); + let charge_before = hooks.budget().snapshot().mem_charged; + assert_eq!(spawns.load(Ordering::SeqCst), 0); + assert!(lane_slot.lock().unwrap().is_none()); + assert_eq!(memory.size(&store), pages_before); + assert_eq!(hooks.budget().snapshot().mem_charged, charge_before); + + let invoke = instance + .exports + .get_typed_function::<(), i32>(&store, "invoke") + .unwrap(); + assert!(invoke.call(&mut store).unwrap() > 0); + assert_eq!(spawns.load(Ordering::SeqCst), 1); + lane_slot.lock().unwrap().as_ref().unwrap().stop(); + finished_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + } + + #[test] + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + fn managed_imports_bind_the_existing_wasix_memory_before_start() { + let spawns = Arc::new(AtomicUsize::new(0)); + let (finished_tx, finished_rx) = mpsc::channel(); + let (hooks, lane_slot) = managed_hooks(Arc::clone(&spawns), finished_tx); + let mut store = Store::default(); + let module = compile_wat( + &store, + r#"(module + (import "napi" "napi_wasm_init_env" (func $init (result i32))) + (import "env" "memory" (memory 1 512)) + (import "env" "uv_get_free_memory" (func $free_memory (result i64))) + (memory (export "memory") 1 512) + (global $result (mut i32) (i32.const 0)) + (func $start call $init global.set $result) + (start $start) + (func (export "start_result") (result i32) global.get $result) + (func (export "helper_result") (result i64) call $free_memory) + )"#, + ); + let memory = + wasmer::Memory::new(&mut store, wasmer::MemoryType::new(1, Some(512), false)).unwrap(); + let mut imports = wasmer::Imports::new(); + imports.define("env", "memory", memory.clone()); + imports.define( + "env", + "uv_get_free_memory", + wasmer::Function::new_typed(&mut store, || 123_i64), + ); + let state = hooks + .add_imports(&module, &mut store.as_store_mut(), &mut imports) + .unwrap(); + let func_env = state + .session + .as_ref() + .unwrap() + .inner + .func_env + .lock() + .unwrap() + .as_ref() + .unwrap() + .clone(); + let bound_memory = func_env.as_ref(&store).memory.clone().unwrap(); + assert_eq!( + bound_memory.view(&store).data_ptr(), + memory.view(&store).data_ptr() + ); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + assert_ne!( + instance + .exports + .get_memory("memory") + .unwrap() + .view(&store) + .data_ptr(), + memory.view(&store).data_ptr(), + "the exported memory must be distinct from imported env.memory" + ); + hooks + .configure_instance(&module, &mut store.as_store_mut(), &instance, None, state) + .unwrap(); + assert!(func_env.as_ref(&store).guest_heap.is_some()); + assert_eq!( + func_env + .as_ref(&store) + .memory + .as_ref() + .unwrap() + .view(&store) + .data_ptr(), + memory.view(&store).data_ptr(), + "configure_instance must retain the memory used by the start-section heap" + ); + assert!( + instance + .exports + .get_typed_function::<(), i32>(&store, "start_result") + .unwrap() + .call(&mut store) + .unwrap() + > 0 + ); + assert_eq!( + instance + .exports + .get_typed_function::<(), i64>(&store, "helper_result") + .unwrap() + .call(&mut store) + .unwrap(), + 123 + ); + assert_eq!(spawns.load(Ordering::SeqCst), 1); + drop(imports); + drop(instance); + drop(store); + lane_slot.lock().unwrap().as_ref().unwrap().stop(); + finished_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + } + + #[test] + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + fn start_section_initialization_requires_a_guest_heap() { + let spawns = Arc::new(AtomicUsize::new(0)); + let (finished_tx, finished_rx) = mpsc::channel(); + let (hooks, lane_slot) = managed_hooks(Arc::clone(&spawns), finished_tx); + let mut store = Store::default(); + + // A module-defined memory cannot be reached by imports until after + // instantiation, so its start section must fail before V8 starts. + let unavailable = compile_wat( + &store, + r#"(module + (import "napi" "napi_wasm_init_env" (func $init (result i32))) + (memory 1 512) + (global $result (mut i32) (i32.const -1)) + (func $start call $init global.set $result) + (start $start) + (func (export "start_result") (result i32) global.get $result) + )"#, + ); + let (imports, _state) = hooks + .additional_imports(&unavailable, &mut store.as_store_mut()) + .unwrap(); + let instance = Instance::new(&mut store, &unavailable, &imports).unwrap(); + let result = instance + .exports + .get_typed_function::<(), i32>(&store, "start_result") + .unwrap(); + assert_eq!(result.call(&mut store).unwrap(), 0); + assert_eq!(spawns.load(Ordering::SeqCst), 0); + assert!(!lane_slot.lock().unwrap().is_some()); + + let unavailable_extension = compile_wat( + &store, + r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" + (func $create (param i32 i32 i32 i32) (result i32))) + (memory 1 512) + (global $result (mut i32) (i32.const -1)) + (func $start + i32.const 8 i32.const 0 i32.const 4 i32.const 8 + call $create global.set $result) + (start $start) + (func (export "start_result") (result i32) global.get $result) + )"#, + ); + let (imports, _state) = hooks + .additional_imports(&unavailable_extension, &mut store.as_store_mut()) + .unwrap(); + let instance = Instance::new(&mut store, &unavailable_extension, &imports).unwrap(); + let result = instance + .exports + .get_typed_function::<(), i32>(&store, "start_result") + .unwrap(); + assert_ne!(result.call(&mut store).unwrap(), 0); + assert_eq!(spawns.load(Ordering::SeqCst), 0); + assert!(!lane_slot.lock().unwrap().is_some()); + + // Imported memory is available during start. The first call installs + // the guest heap before V8, and setup retains that same allocator. + let available = compile_wat( + &store, + r#"(module + (import "napi" "napi_wasm_init_env" (func $init (result i32))) + (import "env" "memory" (memory 1 512)) + (global $result (mut i32) (i32.const -1)) + (func $start call $init global.set $result) + (start $start) + (func (export "start_result") (result i32) global.get $result) + (func (export "invoke") (result i32) call $init) + )"#, + ); + let (imports, state) = hooks + .additional_imports(&available, &mut store.as_store_mut()) + .unwrap(); + let session = state.session.as_ref().unwrap().clone(); + let instance = Instance::new(&mut store, &available, &imports).unwrap(); + let heap_before = session + .inner + .func_env + .lock() + .unwrap() + .as_ref() + .unwrap() + .as_ref(&store) + .guest_heap + .clone() + .expect("start section installs guest heap"); + hooks + .configure_instance( + &available, + &mut store.as_store_mut(), + &instance, + None, + state, + ) + .unwrap(); + let heap_after = session + .inner + .func_env + .lock() + .unwrap() + .as_ref() + .unwrap() + .as_ref(&store) + .guest_heap + .clone() + .unwrap(); + assert!(Arc::ptr_eq(&heap_before, &heap_after)); + let result = instance + .exports + .get_typed_function::<(), i32>(&store, "start_result") + .unwrap(); + let start_id = result.call(&mut store).unwrap(); + assert!(start_id > 0); + let invoke = instance + .exports + .get_typed_function::<(), i32>(&store, "invoke") + .unwrap(); + assert_eq!(invoke.call(&mut store).unwrap(), start_id); + assert_eq!(spawns.load(Ordering::SeqCst), 1); + lane_slot.lock().unwrap().as_ref().unwrap().stop(); + finished_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + } + + #[test] + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + fn stopped_lazy_hooks_never_start_lane() { + let spawns = Arc::new(AtomicUsize::new(0)); + let (finished_tx, _finished_rx) = mpsc::channel(); + let (hooks, lane_slot) = managed_hooks(Arc::clone(&spawns), finished_tx); + hooks.runtime_control().terminate_all(); + let mut store = Store::default(); + let module = compile_wat( + &store, + r#"(module + (import "napi" "napi_wasm_init_env" (func $init (result i32))) + (import "env" "memory" (memory 1 512)) + (func (export "invoke") (result i32) call $init) + )"#, + ); + let (imports, state) = hooks + .additional_imports(&module, &mut store.as_store_mut()) + .unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + hooks + .configure_instance(&module, &mut store.as_store_mut(), &instance, None, state) + .unwrap(); + let invoke = instance + .exports + .get_typed_function::<(), i32>(&store, "invoke") + .unwrap(); + assert_eq!(invoke.call(&mut store).unwrap(), 0); + assert_eq!(spawns.load(Ordering::SeqCst), 0); + assert!(!lane_slot.lock().unwrap().is_some()); + } + #[test] fn configure_instance_pairs_sessions_by_state() { let mut store_a = Store::default(); diff --git a/src/env.rs b/src/env.rs index 1dc3fed5..e7dc0db2 100644 --- a/src/env.rs +++ b/src/env.rs @@ -1,10 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::ffi::c_void; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; - -#[cfg(all(target_arch = "wasm32", feature = "js"))] -use std::sync::atomic::{AtomicU32, Ordering as AtomicOrdering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; #[cfg(all(target_arch = "wasm32", feature = "js"))] use wasmer::TypedFunction; @@ -14,10 +11,13 @@ use crate::budget::{ EnvHeapCharge, EnvRejected, HeapReservation, Pool, RequestedHeap, ResourceBudget, }; use crate::id_hash::IdHashMap; +#[cfg(not(all(target_arch = "wasm32", feature = "js")))] +use crate::lane::{ManagedV8Lane, ManagedV8LaneActivator, ManagedV8LaneScope}; +use crate::message::PendingMessages; +#[cfg(not(all(target_arch = "wasm32", feature = "js")))] +use crate::snapi::snapi_bridge_unofficial_env_alive; use crate::snapi::{ - SnapiEnv, snapi_bridge_unofficial_release_env, - snapi_bridge_unofficial_set_host_near_heap_limit_callback, - snapi_bridge_unofficial_set_value_limit, + SnapiEnv, snapi_bridge_unofficial_release_env, snapi_bridge_unofficial_set_host_budget, }; #[cfg(all(target_arch = "wasm32", feature = "js"))] @@ -70,13 +70,12 @@ pub(crate) struct NativeBufferLease { static NEXT_NAPI_ENV_ID: AtomicU32 = AtomicU32::new(1); #[cfg(all(target_arch = "wasm32", feature = "js"))] -fn next_js_napi_env_id() -> u32 { - loop { - let id = NEXT_NAPI_ENV_ID.fetch_add(1, AtomicOrdering::Relaxed); - if id != 0 { - return id; - } - } +fn next_js_napi_env_id() -> Option { + NEXT_NAPI_ENV_ID + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| { + (id <= i32::MAX as u32).then_some(id + 1) + }) + .ok() } /// Bookkeeping for one live V8 env's heap charge: the initial ceiling plus a @@ -93,10 +92,18 @@ pub(crate) struct NapiEnv { /// `max_envs` isolate count are charged against it; workers share the same /// `Arc` so the app-wide budget stays honest across stores. pub(crate) budget: Arc, + pub(crate) pending_messages: Arc, /// Per-app cap on live V8 isolates (`None` = unlimited). pub(crate) max_envs: Option, + /// Holds the import-session admission slot for as long as this store owns + /// its host-function environment, even after import setup has returned. + pub(crate) session_lease: Option>, env_registry: Arc>>, host_stopped: Arc, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + managed_lane_activator: Option, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + managed_lane: Option>, /// Heap charge per live V8 env, keyed by guest env id, so teardown releases /// exactly what creation charged plus what the callback later granted. env_heap_charges: HashMap, @@ -110,13 +117,14 @@ pub(crate) struct NapiEnv { /// (an uncatchable SIGSEGV). See [`NapiEnv::enter_callback`]. callback_depth: u32, pub(crate) memory: Option, - /// Host-side allocator over the guest's linear memory. `None` only before - /// `NapiSession::configure_instance` runs; that call fails instantiation - /// outright if a `GuestHeap` can't be built, so any env reachable from - /// guest code is guaranteed to have one — it is the only allocation path - /// for guest-visible V8 memory, never a best-effort fallback. + /// Host-side allocator over the guest's linear memory. Created on first + /// environment use, so importing N-API alone does not grow guest memory. + /// Environment creation fails if the heap cannot be built; once created, + /// this is the only allocation path for guest-visible V8 memory. #[cfg(not(all(target_arch = "wasm32", feature = "js")))] pub(crate) guest_heap: Option>, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + pub(crate) guest_heap_registration: Option, #[cfg(all(target_arch = "wasm32", feature = "js"))] pub(crate) malloc_fn: Option>, pub(crate) table: Option, @@ -130,7 +138,8 @@ pub(crate) struct NapiEnv { /// unconditional store growth. See `guest::callback::call_guest_callback`. pub(crate) func_cache: HashMap, pub(crate) default_napi_env_id: Option, - pub(crate) next_napi_env_id: u32, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + next_native_env_id: Arc, pub(crate) next_napi_scope_id: u32, pub(crate) napi_envs: IdHashMap, pub(crate) napi_state_to_guest_env: IdHashMap, @@ -168,28 +177,42 @@ pub(crate) struct NapiEnv { impl NapiEnv { pub(crate) fn new( budget: Arc, + pending_messages: Arc, max_envs: Option, env_registry: Arc>>, host_stopped: Arc, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] next_native_env_id: Arc, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] managed_lane_activator: Option< + ManagedV8LaneActivator, + >, ) -> Self { Self { budget, + pending_messages, max_envs, + session_lease: None, env_registry, host_stopped, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + managed_lane_activator, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + managed_lane: None, env_heap_charges: HashMap::new(), external_declared: 0, callback_depth: 0, memory: None, #[cfg(not(all(target_arch = "wasm32", feature = "js")))] guest_heap: None, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + guest_heap_registration: None, #[cfg(all(target_arch = "wasm32", feature = "js"))] malloc_fn: None, table: None, func_cache: HashMap::new(), default_napi_env_id: None, - next_napi_env_id: 0, - next_napi_scope_id: 0, + next_napi_scope_id: 1, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + next_native_env_id, napi_envs: IdHashMap::default(), napi_state_to_guest_env: IdHashMap::default(), napi_scopes: IdHashMap::default(), @@ -224,6 +247,26 @@ impl NapiEnv { } } + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + pub(crate) fn enter_background_lane(&mut self) -> anyhow::Result> { + if self.host_stopped.load(Ordering::Acquire) { + anyhow::bail!("N-API instance has been stopped"); + } + let Some(activate) = &self.managed_lane_activator else { + return Ok(None); + }; + if self.managed_lane.is_none() { + let lane = activate()?; + // Activation can race a host stop. Edge rejects late activation, + // and the provider rechecks its own sticky stop flag before V8. + if self.host_stopped.load(Ordering::Acquire) { + anyhow::bail!("N-API instance stopped during activation"); + } + self.managed_lane = Some(lane); + } + Ok(self.managed_lane.as_ref().map(ManagedV8Lane::enter)) + } + #[cfg(all(target_arch = "wasm32", feature = "js"))] pub(crate) fn next_host_buffer_owner(&mut self) -> u64 { loop { @@ -264,15 +307,16 @@ impl NapiEnv { } /// Register a successfully-created env, attach its heap charge, and — under - /// a limited budget — install the host-owned near-heap-limit callback so V8 - /// heap growth for this isolate is charged against the budget. Teardown - /// releases both the initial ceiling and any granted growth. + /// a limited budget — install the host-owned budget tracker so V8 heap + /// growth and the bridge's per-handle bookkeeping for this isolate are + /// charged against the budget. Teardown releases the initial ceiling and + /// everything granted since. pub(crate) fn commit_isolate( &mut self, env: SnapiEnv, reservation: &HeapReservation, - ) -> (u32, u32) { - let (env_id, scope_id) = self.register_napi_env(env); + ) -> Option<(u32, u32)> { + let (env_id, scope_id) = self.register_napi_env(env)?; { let mut registry = self .env_registry @@ -301,26 +345,13 @@ impl NapiEnv { host_stopped: Arc::clone(&self.host_stopped), unwind_slack_available: std::sync::atomic::AtomicBool::new(true), granted: AtomicU64::new(0), + bookkeeping_granted: AtomicU64::new(0), })); // SAFETY: `env` is the isolate just created; `boxed` outlives the - // callback (freed only at this env's teardown, below). + // bridge's hooks (freed only at this env's teardown, below). unsafe { - snapi_bridge_unofficial_set_host_near_heap_limit_callback( - env, - boxed as *const c_void, - ); - } - - // Cap per-value host handles / callback registrations so the C++ - // bookkeeping (invisible to the byte pools) cannot grow host RSS - // without bound; derived from the budget. - if let Some(limit) = self.budget.value_handle_limit() { - // SAFETY: `env` is the isolate just created. - unsafe { - snapi_bridge_unofficial_set_value_limit(env, limit); - } + snapi_bridge_unofficial_set_host_budget(env, boxed as *const c_void); } - boxed as usize } else { 0 @@ -333,7 +364,7 @@ impl NapiEnv { tracker, }, ); - (env_id, scope_id) + Some((env_id, scope_id)) } /// Release a reservation whose env creation failed after [`reserve_isolate`]. @@ -366,6 +397,27 @@ impl NapiEnv { self.host_stopped.load(Ordering::Acquire) } + /// Serialize guest cancellation with host termination. The host sets its + /// sticky flag before taking this registry lock and terminates every live + /// isolate while holding it. If a kill races this call, either cancellation + /// is refused or the kill's termination happens after cancellation. + pub(crate) fn cancel_guest_termination(&self, env: SnapiEnv) -> i32 { + let _registry = self + .env_registry + .lock() + .expect("poisoned N-API env registry"); + if self.host_stopped() { + if !env.is_null() { + unsafe { crate::snapi::snapi_bridge_unofficial_terminate_execution(env) }; + } + return 1; + } + if env.is_null() { + return 1; + } + unsafe { crate::snapi::snapi_bridge_unofficial_cancel_terminate_execution(env) } + } + /// Claim one level of guest↔host callback reentrancy. Returns `false` (the /// callback must be refused) once [`MAX_CALLBACK_DEPTH`] is reached, so a /// runaway recursion across the FFI boundary cannot overflow the host native @@ -388,23 +440,37 @@ impl NapiEnv { self.callback_depth = self.callback_depth.saturating_sub(1); } - pub(crate) fn register_napi_env(&mut self, env: SnapiEnv) -> (u32, u32) { + pub(crate) fn in_callback(&self) -> bool { + self.callback_depth != 0 + } + + pub(crate) fn scope_env(&self, scope_id: u32) -> Option<(u32, SnapiEnv)> { + let env_id = *self.napi_scopes.get(&scope_id)?; + let env = *self.napi_envs.get(&env_id)? as SnapiEnv; + Some((env_id, env)) + } + + pub(crate) fn register_napi_env(&mut self, env: SnapiEnv) -> Option<(u32, u32)> { + if self.next_napi_scope_id > i32::MAX as u32 { + return None; + } #[cfg(not(all(target_arch = "wasm32", feature = "js")))] - let env_id = { - let env_id = self.next_napi_env_id.max(1); - self.next_napi_env_id = env_id.saturating_add(1); - env_id - }; + let env_id = self + .next_native_env_id + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| { + (id <= i32::MAX as u32).then_some(id + 1) + }) + .ok()?; #[cfg(all(target_arch = "wasm32", feature = "js"))] - let env_id = next_js_napi_env_id(); + let env_id = next_js_napi_env_id()?; - let scope_id = self.next_napi_scope_id.max(1); - self.next_napi_scope_id = scope_id.saturating_add(1); + let scope_id = self.next_napi_scope_id; + self.next_napi_scope_id += 1; self.napi_envs.insert(env_id, env as usize); self.napi_state_to_guest_env.insert(env as usize, env_id); self.napi_scopes.insert(scope_id, env_id); - (env_id, scope_id) + Some((env_id, scope_id)) } fn discard_buffer_leases_for_env(&mut self, env_id: u32) { @@ -449,16 +515,6 @@ impl NapiEnv { } } - pub(crate) fn in_callback(&self) -> bool { - self.callback_depth != 0 - } - - pub(crate) fn scope_env(&self, scope_id: u32) -> Option<(u32, SnapiEnv)> { - let env_id = *self.napi_scopes.get(&scope_id)?; - let env = *self.napi_envs.get(&env_id)?; - Some((env_id, env as SnapiEnv)) - } - pub(crate) fn begin_unregister_napi_scope(&mut self, scope_id: u32) -> Option<(u32, SnapiEnv)> { let env_id = self.napi_scopes.remove(&scope_id)?; if self.default_napi_env_id == Some(env_id) { @@ -481,21 +537,21 @@ impl NapiEnv { } pub(crate) fn finish_unregister_napi_env(&mut self, env_id: u32, env: SnapiEnv) { - // Release the heap ceiling + any callback-granted growth + isolate slot - // this env reserved. + // Release the heap ceiling + any granted growth and bookkeeping + + // isolate slot this env reserved. if let Some(handle) = self.env_heap_charges.remove(&env_id) { - let granted = if handle.tracker != 0 { + if handle.tracker != 0 { // SAFETY: reclaim the box created in `commit_isolate`. The - // near-heap-limit callback holding this pointer only runs during - // JS execution, and no JS runs between here and the paired env - // release that removes the callback and disposes the isolate, so - // freeing it now is safe. + // native release has already removed the callback and disposed + // the isolate, so it cannot call through this pointer again. let boxed = unsafe { Box::from_raw(handle.tracker as *mut EnvHeapCharge) }; - boxed.granted.load(Ordering::Acquire) - } else { - 0 - }; - self.budget.uncharge(Pool::V8HeapReserved, granted); + self.budget + .uncharge(Pool::V8HeapReserved, boxed.granted.load(Ordering::Acquire)); + self.budget.uncharge( + Pool::HostBookkeeping, + boxed.bookkeeping_granted.load(Ordering::Acquire), + ); + } self.budget.release_env(handle.ceiling); } self.napi_envs.remove(&env_id); @@ -507,6 +563,7 @@ impl NapiEnv { self.napi_state_to_guest_env.remove(&(env as usize)); } + #[cfg(all(target_arch = "wasm32", feature = "js"))] pub(crate) fn unregister_napi_scope(&mut self, scope_id: u32) -> Option { let (env_id, env) = self.begin_unregister_napi_scope(scope_id)?; self.finish_unregister_napi_env(env_id, env); @@ -522,41 +579,167 @@ impl NapiEnv { .map(|env| *env as SnapiEnv) .unwrap_or(std::ptr::null_mut()) } -} -impl Drop for NapiEnv { - fn drop(&mut self) { + fn release_registered_envs(&mut self, mut release: impl FnMut(SnapiEnv) -> bool) -> bool { + let mut all_quiesced = true; let scope_ids: Vec = self.napi_scopes.keys().copied().collect(); for scope_id in scope_ids { - if let Some(env) = self.unregister_napi_scope(scope_id) { + let Some((env_id, env)) = self.begin_unregister_napi_scope(scope_id) else { + continue; + }; + // Native release can wait for V8 background work and finalizers. + // Keep the isolate's full reservation in the shared accountant + // until that work is quiescent; another WASIX worker may try to + // create an environment concurrently. + if release(env) { + self.finish_unregister_napi_env(env_id, env); + } else { + all_quiesced = false; + // A failed native release has not proved that the isolate is + // gone. Retain the charge and heap-limit callback backing + // rather than making its bytes available to a sibling. + eprintln!("[wasmer-napi] env {env_id} release failed during drop; retaining quota"); + self.host_stopped.store(true, Ordering::Release); + self.env_registry + .lock() + .expect("poisoned N-API env registry") + .insert(env as usize); + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] unsafe { - let _ = snapi_bridge_unofficial_release_env(env); + crate::snapi::snapi_bridge_unofficial_terminate_execution(env); } + std::mem::forget(Arc::clone(&self.budget)); } } + all_quiesced + } +} + +impl Drop for NapiEnv { + fn drop(&mut self) { + let all_quiesced = self.release_registered_envs(|env| { + let status = unsafe { snapi_bridge_unofficial_release_env(env) }; + if status == 0 { + return true; + } + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + { + // A finalizer error can be returned after complete native + // disposal. Only a still-registered bridge requires keeping + // the reservation charged. + unsafe { snapi_bridge_unofficial_env_alive(env) == 0 } + } + #[cfg(all(target_arch = "wasm32", feature = "js"))] + false + }); // Release any external memory the guest declared but did not take back. - if self.external_declared > 0 { + if all_quiesced && self.external_declared > 0 { self.budget .uncharge(Pool::V8External, self.external_declared); self.external_declared = 0; } + if !all_quiesced { + // A still-registered native env keeps a raw pointer to its V8 + // queue. Preserve that owner and its session admission slot along + // with the quota retained above; dropping either would let later + // work touch freed memory or admit another live session. + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + if let Some(lane) = self.managed_lane.take() { + std::mem::forget(lane); + } + if let Some(lease) = self.session_lease.take() { + std::mem::forget(lease); + } + } + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + if let (Some(heap), Some(registration)) = + (&self.guest_heap, self.guest_heap_registration.take()) + { + heap.unregister_memory(registration); + } } } #[cfg(test)] mod tests { use super::*; + #[cfg(not(target_arch = "wasm32"))] + use std::{sync::mpsc, thread}; const MIB: u64 = 1024 * 1024; + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + #[test] + fn native_env_ids_are_unique_across_concurrent_instance_sessions() { + let next_id = Arc::new(AtomicU32::new(1)); + let budget = ResourceBudget::unlimited(); + let pending = PendingMessages::new(); + let registry = Arc::new(std::sync::Mutex::new(HashSet::new())); + let stopped = Arc::new(AtomicBool::new(false)); + let barrier = Arc::new(std::sync::Barrier::new(24)); + let mut joins = Vec::new(); + for index in 0..24usize { + let next_id = Arc::clone(&next_id); + let budget = Arc::clone(&budget); + let pending = Arc::clone(&pending); + let registry = Arc::clone(®istry); + let stopped = Arc::clone(&stopped); + let barrier = Arc::clone(&barrier); + joins.push(std::thread::spawn(move || { + let mut session = + NapiEnv::new(budget, pending, None, registry, stopped, next_id, None); + barrier.wait(); + let (id, _) = session.register_napi_env((index + 1) as SnapiEnv).unwrap(); + // The fake pointer only exercises ID allocation; never pass it + // through the native release path in NapiEnv::drop. + session.napi_envs.clear(); + session.napi_state_to_guest_env.clear(); + session.napi_scopes.clear(); + id + })); + } + let mut ids: Vec = joins.into_iter().map(|join| join.join().unwrap()).collect(); + ids.sort_unstable(); + assert_eq!(ids, (1..=24).collect::>()); + assert_eq!(next_id.load(Ordering::Relaxed), 25); + } + + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + #[test] + fn native_env_ids_reject_exhaustion_without_reuse() { + let next_id = Arc::new(AtomicU32::new(i32::MAX as u32)); + let mut session = NapiEnv::new( + ResourceBudget::unlimited(), + PendingMessages::new(), + None, + Arc::new(std::sync::Mutex::new(HashSet::new())), + Arc::new(AtomicBool::new(false)), + Arc::clone(&next_id), + None, + ); + assert_eq!( + session.register_napi_env(1usize as SnapiEnv).unwrap().0, + i32::MAX as u32 + ); + assert!(session.register_napi_env(2usize as SnapiEnv).is_none()); + session.napi_envs.clear(); + session.napi_state_to_guest_env.clear(); + session.napi_scopes.clear(); + } + #[test] fn declared_external_charges_denies_and_clamps() { let budget = ResourceBudget::with_memory_limit(10 * MIB); let mut env = NapiEnv::new( Arc::clone(&budget), + PendingMessages::new(), None, Arc::new(std::sync::Mutex::new(HashSet::new())), Arc::new(AtomicBool::new(false)), + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + Arc::new(AtomicU32::new(1)), + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + None, ); assert!(env.charge_declared_external(6 * MIB)); @@ -583,9 +766,14 @@ mod tests { { let mut env = NapiEnv::new( Arc::clone(&budget), + PendingMessages::new(), None, Arc::new(std::sync::Mutex::new(HashSet::new())), Arc::new(AtomicBool::new(false)), + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + Arc::new(AtomicU32::new(1)), + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + None, ); assert!(env.charge_declared_external(4 * MIB)); assert_eq!(budget.snapshot().v8_external, 4 * MIB); @@ -597,13 +785,71 @@ mod tests { ); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn dropping_env_keeps_quota_reserved_until_native_release_finishes() { + let budget = ResourceBudget::with_memory_limit(110 * MIB); + let mut env = NapiEnv::new( + Arc::clone(&budget), + PendingMessages::new(), + None, + Arc::new(std::sync::Mutex::new(HashSet::new())), + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicU32::new(1)), + None, + ); + let reservation = env.reserve_isolate(RequestedHeap::default()).unwrap(); + // The release callback below is deliberately substituted so the + // cross-thread check can pause at the native quiescence boundary. + let fake_env = 1usize as SnapiEnv; + let (env_id, _) = env.register_napi_env(fake_env).unwrap(); + env.env_heap_charges.insert( + env_id, + EnvHeapChargeHandle { + ceiling: reservation.ceiling_bytes, + tracker: 0, + }, + ); + let (entered_tx, entered_rx) = mpsc::channel(); + let (finish_tx, finish_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + env.release_registered_envs(|_| { + entered_tx.send(()).unwrap(); + finish_rx.recv().unwrap(); + true + }); + }); + entered_rx.recv().unwrap(); + assert_eq!( + budget.snapshot().v8_heap_reserved, + reservation.ceiling_bytes + ); + assert!( + budget + .try_reserve_env(RequestedHeap::default(), None) + .is_err() + ); + finish_tx.send(()).unwrap(); + worker.join().unwrap(); + assert_eq!(budget.snapshot().v8_heap_reserved, 0); + let later = budget + .try_reserve_env(RequestedHeap::default(), None) + .unwrap(); + budget.release_env(later.ceiling_bytes); + } + #[test] fn callback_reentrancy_is_bounded() { let mut env = NapiEnv::new( ResourceBudget::unlimited(), + PendingMessages::new(), None, Arc::new(std::sync::Mutex::new(HashSet::new())), Arc::new(AtomicBool::new(false)), + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + Arc::new(AtomicU32::new(1)), + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + None, ); let max = crate::guest::MAX_CALLBACK_DEPTH; diff --git a/src/guest/abi.rs b/src/guest/abi.rs index 7ba1584d..a264a948 100644 --- a/src/guest/abi.rs +++ b/src/guest/abi.rs @@ -164,6 +164,7 @@ pub(crate) fn read_output_header( .then_some(size) } +#[derive(Default)] pub(crate) struct EnvCreate { pub total_memory: u64, pub constrained_memory: u64, @@ -173,29 +174,60 @@ pub(crate) struct EnvCreate { pub stack_limit: u32, } -pub(crate) fn read_runtime_options( +/// Longest engine flag string that is read from the guest. Every inert +/// default is shorter, so longer strings are rejected without a host copy. +const MAX_ENGINE_FLAGS_LENGTH: u32 = 64; + +/// Whether guest runtime options only request inert engine flags. Guests are +/// never permitted to set process-wide V8 flags, but EdgeJS unconditionally +/// passes its fixed defaults before it creates an env; those are accepted +/// without being forwarded. `None` means the options are malformed. +pub(crate) fn runtime_options_are_inert( env: &mut FunctionEnvMut, guest_ptr: u32, -) -> Option> { +) -> Option { if guest_ptr == 0 { - return Some(Vec::new()); + return Some(true); } let bytes = read_versioned(env, guest_ptr, RUNTIME_OPTIONS_SIZE, 1)?; - let flags_ptr = u32_at( + let flags = u32_at( &bytes, std::mem::offset_of!(Wasm32RuntimeOptionsV1, engine_flags), )?; let flags_length = u32_at( &bytes, std::mem::offset_of!(Wasm32RuntimeOptionsV1, engine_flags_length), - )? as usize; - if flags_length == 0 { - return Some(Vec::new()); + )?; + engine_flags_are_inert(env, flags, flags_length) +} + +/// Whether a guest engine flag string only holds EdgeJS's fixed defaults. +/// `None` means the string cannot be read. +pub(crate) fn engine_flags_are_inert( + env: &mut FunctionEnvMut, + guest_ptr: u32, + length: u32, +) -> Option { + if length == 0 { + return Some(true); + } + if length > MAX_ENGINE_FLAGS_LENGTH { + return Some(false); } - if flags_ptr == 0 { + if guest_ptr == 0 { return None; } - read_guest_bytes(env, flags_ptr, flags_length) + let bytes = read_guest_bytes(env, guest_ptr, length as usize)?; + Some(is_default_engine_flags(&bytes)) +} + +fn is_default_engine_flags(bytes: &[u8]) -> bool { + matches!( + bytes, + b"" | b"--js-source-phase-imports" + | b"--harmony-import-attributes" + | b"--js-source-phase-imports --harmony-import-attributes" + ) } pub(crate) fn read_env_create( diff --git a/src/guest/napi.rs b/src/guest/napi.rs index ca819ed2..50aaaa9f 100644 --- a/src/guest/napi.rs +++ b/src/guest/napi.rs @@ -22,6 +22,15 @@ use crate::{ use super::{abi, util::*}; fn guest_napi_wasm_init_env(mut env: FunctionEnvMut) -> i32 { + if env.data().memory.is_none() { + return 0; + } + let Ok(_lane_scope) = env.data_mut().enter_background_lane() else { + return 0; + }; + if !ensure_guest_heap(&mut env) { + return 0; + } let _ = unsafe { snapi_bridge_init() }; // Legacy N-API modules without an embedder-owned runtime configuration use // the provider default. If Edge configured flags first, this conflicting @@ -60,11 +69,47 @@ fn guest_napi_wasm_init_env(mut env: FunctionEnvMut) -> i32 { return 0; } - let (env_id, _scope_id) = env.data_mut().commit_isolate(snapi_env_state, &reservation); + let Some((env_id, _scope_id)) = env.data_mut().commit_isolate(snapi_env_state, &reservation) + else { + unsafe { snapi_bridge_unofficial_release_env(snapi_env_state) }; + env.data().abort_isolate(&reservation); + return 0; + }; env.data_mut().default_napi_env_id = Some(env_id); env_id as i32 } +// A Wasm start section can call N-API before configure_instance runs. An +// imported env.memory is already available then, so install its guest heap on +// first use. If no usable memory exists, fail before spawning V8: a null heap +// would make this guest look like an unrestricted native embedder. +#[cfg(not(all(target_arch = "wasm32", feature = "js")))] +fn ensure_guest_heap(env: &mut FunctionEnvMut) -> bool { + if env.data().host_stopped() { + return false; + } + if env.data().guest_heap.is_some() { + return true; + } + let Some(memory) = env.data().memory.clone() else { + return false; + }; + let budget = std::sync::Arc::clone(&env.data().budget); + let Some((heap, registration)) = + crate::guest_heap::GuestHeap::get_or_create(&mut env.as_store_mut(), &memory, budget) + else { + return false; + }; + env.data_mut().guest_heap = Some(heap); + env.data_mut().guest_heap_registration = Some(registration); + true +} + +#[cfg(all(target_arch = "wasm32", feature = "js"))] +fn ensure_guest_heap(_env: &mut FunctionEnvMut) -> bool { + true +} + /// Boxed guest-heap context for env creation, or null when no heap exists. /// The bridge takes ownership in all cases (success or failure). fn guest_heap_alloc_ctx(env: &FunctionEnvMut) -> *const std::ffi::c_void { @@ -197,15 +242,18 @@ fn guest_unofficial_napi_configure_runtime( mut env: FunctionEnvMut, options_ptr: u32, ) -> i32 { - let Some(flags) = abi::read_runtime_options(&mut env, options_ptr) else { - return 1; - }; // V8 flags are process-wide. A guest must not change flags for other // tenants or make the first caller decide the host's security policy. - if !flags.is_empty() { + let Some(true) = abi::runtime_options_are_inert(&mut env, options_ptr) else { + return 1; + }; + if env.data().host_stopped() { return 1; } - unsafe { snapi_bridge_unofficial_configure_runtime(std::ptr::null(), 0) } + // Configuration has no per-guest mutable options. Defer native V8 + // platform creation until env creation has activated the embedder's + // metered lane and admitted this instance. + 0 } fn guest_unofficial_napi_create_env( @@ -215,33 +263,49 @@ fn guest_unofficial_napi_create_env( env_out_ptr: u32, scope_out_ptr: u32, ) -> i32 { - let ( - total_memory, - constrained_memory, - max_young_generation_size_in_bytes, - max_old_generation_size_in_bytes, - code_range_size_in_bytes, - stack_limit, - ) = if options_ptr != 0 { + let options = if options_ptr == 0 { + abi::EnvCreate::default() + } else { let Some(options) = abi::read_env_create(&mut env, options_ptr) else { return 1; }; - ( - options.total_memory, - options.constrained_memory, - options.max_young_generation_size_in_bytes, - options.max_old_generation_size_in_bytes, - options.code_range_size_in_bytes, - options.stack_limit, - ) - } else { - (0, 0, 0, 0, 0, 0) + options }; + create_env_with_options(env, module_api_version, options, env_out_ptr, scope_out_ptr) +} +fn create_env_with_options( + mut env: FunctionEnvMut, + module_api_version: i32, + options: abi::EnvCreate, + env_out_ptr: u32, + scope_out_ptr: u32, +) -> i32 { + // Reject malformed output pointers before creating a V8 isolate. A bad + // result address would otherwise leave the guest with an unreachable env. + if env_out_ptr == 0 + || scope_out_ptr == 0 + || read_guest_bytes(&mut env, env_out_ptr, 4).is_none() + || read_guest_bytes(&mut env, scope_out_ptr, 4).is_none() + { + return 1; + } + if env.data().memory.is_none() { + return 1; + } + let Ok(_lane_scope) = env.data_mut().enter_background_lane() else { + return 1; + }; + if !ensure_guest_heap(&mut env) { + return 1; + } + if unsafe { snapi_bridge_unofficial_configure_runtime(std::ptr::null(), 0) } != 0 { + return 1; + } let requested = RequestedHeap { - max_young: max_young_generation_size_in_bytes, - max_old: max_old_generation_size_in_bytes, - code_range: code_range_size_in_bytes, + max_young: options.max_young_generation_size_in_bytes, + max_old: options.max_old_generation_size_in_bytes, + code_range: options.code_range_size_in_bytes, }; let reservation = match env.data().reserve_isolate(requested) { Ok(reservation) => reservation, @@ -253,12 +317,12 @@ fn guest_unofficial_napi_create_env( let status = unsafe { snapi_bridge_unofficial_create_env_with_options( module_api_version, - total_memory, - constrained_memory, + options.total_memory, + options.constrained_memory, reservation.max_young, reservation.max_old, reservation.code_range, - stack_limit, + options.stack_limit, guest_heap_ctx, &mut snapi_env_state, ) @@ -267,13 +331,14 @@ fn guest_unofficial_napi_create_env( env.data().abort_isolate(&reservation); return status; } - let (env_id, scope_id) = env.data_mut().commit_isolate(snapi_env_state, &reservation); - if env_out_ptr != 0 { - write_guest_u32(&mut env, env_out_ptr, env_id); - } - if scope_out_ptr != 0 { - write_guest_u32(&mut env, scope_out_ptr, scope_id); - } + let Some((env_id, scope_id)) = env.data_mut().commit_isolate(snapi_env_state, &reservation) + else { + unsafe { snapi_bridge_unofficial_release_env(snapi_env_state) }; + env.data().abort_isolate(&reservation); + return 1; + }; + write_guest_u32(&mut env, env_out_ptr, env_id); + write_guest_u32(&mut env, scope_out_ptr, scope_id); 0 } @@ -282,7 +347,8 @@ fn guest_unofficial_napi_release_env( scope_ptr: u32, loop_ptr: u32, ) -> Result { - // Disposing an isolate while its JavaScript callback is entered aborts V8. + // Disposing an entered V8 isolate from its own JS/Wasm callback aborts + // the shared host. Leave all registries intact when this is attempted. if env.data().in_callback() { return Ok(1); } @@ -294,9 +360,11 @@ fn guest_unofficial_napi_release_env( let status = with_cb_context(&mut env, guest_env, || unsafe { snapi_bridge_unofficial_release_env_with_loop(snapi_env_state, loop_id) }); - if status.as_ref().is_ok_and(|code| *code == 0) - && let Some((guest_env, snapi_env_state)) = - env.data_mut().begin_unregister_napi_scope(scope_id) + if status.as_ref().is_ok_and(|code| { + *code == 0 + || unsafe { crate::snapi::snapi_bridge_unofficial_env_alive(snapi_env_state) == 0 } + }) && let Some((guest_env, snapi_env_state)) = + env.data_mut().begin_unregister_napi_scope(scope_id) { env.data_mut() .finish_unregister_napi_env(guest_env, snapi_env_state); @@ -609,27 +677,20 @@ fn guest_unofficial_napi_cancel_terminate_execution( napi_env: u32, ) -> i32 { let env_handle = snapi_env(&env, napi_env); - if env.data().host_stopped() { - unsafe { - snapi_bridge_unofficial_terminate_execution(env_handle); - } - return 1; - } - unsafe { snapi_bridge_unofficial_cancel_terminate_execution(env_handle) } + env.data().cancel_guest_termination(env_handle) } fn guest_unofficial_napi_request_interrupt( - mut env: FunctionEnvMut, - napi_env: u32, - callback: u32, - data: u32, -) -> Result { - let env_handle = snapi_env(&env, napi_env); - let callback_id = callback; - let data_val = data; - with_cb_context(&mut env, napi_env, || unsafe { - snapi_bridge_unofficial_request_interrupt(env_handle, napi_env, callback_id, data_val) - }) + _env: FunctionEnvMut, + _napi_env: u32, + _callback: u32, + _data: u32, +) -> i32 { + // V8 queues an opaque native request pointer until JS reaches an + // interrupt point. A guest can enqueue indefinitely without entering JS; + // teardown does not reclaim that pointer. Host stop uses its own control + // path, so guest requests must wait for a tracked, bounded implementation. + 1 } fn guest_unofficial_napi_structured_clone( @@ -5138,7 +5199,9 @@ fn guest_env_ossl_set_max_threads(_ctx: i32, _max_threads: i64) -> i32 { pub fn register_env_imports(store: &mut impl AsStoreMut, io: &mut Imports) { macro_rules! reg_env { ($name:expr, $func:expr) => { - io.define("env", $name, Function::new_typed(store, $func)); + if io.get_export("env", $name).is_none() { + io.define("env", $name, Function::new_typed(store, $func)); + } }; } diff --git a/src/guest/napi_js.rs b/src/guest/napi_js.rs index 1b027dc3..42a7f625 100644 --- a/src/guest/napi_js.rs +++ b/src/guest/napi_js.rs @@ -55,7 +55,10 @@ fn guest_napi_wasm_init_env(mut env: FunctionEnvMut) -> i32 { #[cfg(all(target_arch = "wasm32", feature = "js"))] let async_env = env.as_async_mut(); - let (env_id, _scope_id) = env.data_mut().register_napi_env(snapi_env_state); + let Some((env_id, _scope_id)) = env.data_mut().register_napi_env(snapi_env_state) else { + unsafe { snapi_bridge_unofficial_release_env(snapi_env_state) }; + return 0; + }; #[cfg(all(target_arch = "wasm32", feature = "js"))] if let Some(async_env) = async_env { install_persistent_callback_state(&mut env, env_id, snapi_env_state, async_env); @@ -487,12 +490,9 @@ fn guest_unofficial_napi_configure_runtime( mut env: FunctionEnvMut, options_ptr: u32, ) -> i32 { - let Some(flags) = abi::read_runtime_options(&mut env, options_ptr) else { + let Some(true) = abi::runtime_options_are_inert(&mut env, options_ptr) else { return 1; }; - if !flags.is_empty() { - return 1; - } unsafe { snapi_bridge_unofficial_configure_runtime(std::ptr::null(), 0) } } @@ -545,7 +545,10 @@ fn guest_unofficial_napi_create_env( } #[cfg(all(target_arch = "wasm32", feature = "js"))] let async_env = env.as_async_mut(); - let (env_id, scope_id) = env.data_mut().register_napi_env(snapi_env_state); + let Some((env_id, scope_id)) = env.data_mut().register_napi_env(snapi_env_state) else { + unsafe { snapi_bridge_unofficial_release_env(snapi_env_state) }; + return 1; + }; #[cfg(all(target_arch = "wasm32", feature = "js"))] if let Some(async_env) = async_env { install_persistent_callback_state(&mut env, env_id, snapi_env_state, async_env); @@ -5695,7 +5698,9 @@ fn guest_env_ossl_set_max_threads(_ctx: i32, _max_threads: i64) -> i32 { pub fn register_env_imports(store: &mut impl AsStoreMut, io: &mut Imports) { macro_rules! reg_env { ($name:expr, $func:expr) => { - io.define("env", $name, Function::new_typed(store, $func)); + if io.get_export("env", $name).is_none() { + io.define("env", $name, Function::new_typed(store, $func)); + } }; } diff --git a/src/guest_heap.rs b/src/guest_heap.rs index 5b912abd..58940891 100644 --- a/src/guest_heap.rs +++ b/src/guest_heap.rs @@ -67,7 +67,7 @@ use std::sync::{ use offset_allocator::{Allocation, Allocator as OffsetAllocator}; use wasmer::sys::NativeEngineExt; -use wasmer::{AsStoreRef, Memory, MemoryStyle, Pages, Store, StoreMut}; +use wasmer::{Memory, MemoryStyle, Pages, Store, StoreMut}; use crate::budget::{Pool, ResourceBudget}; @@ -112,7 +112,8 @@ struct HeapInner { /// One handle to this heap's linear memory per store that has reached it. /// A handle only works with the store that made it, so growing means /// finding the one belonging to the store currently lent. - memories: Vec, + memories: Vec<(u64, Memory)>, + next_memory_registration: u64, chunks: Vec, live: HashMap, /// Free bytes across all chunks (maintained on alloc/free/claim). @@ -135,9 +136,13 @@ pub(crate) struct GuestHeap { inner: Mutex, } -// SAFETY: `base` points into a mapping that outlives the heap: the instance's -// store keeps it alive, and the heap is dropped with the env. All mutable -// state is behind the mutex or atomics. +// SAFETY: `base` is an address token, not an owned pointer. A serialized +// message can retain a nonshared GuestHeap after its Store has unmapped that +// address. Allocation requires a live registered memory; after the final +// registration leaves, message teardown only compares addresses and frees +// allocator metadata, without dereferencing `base`. Shared messages also +// retain a detached SharedMemory handle until their native payload is gone. +// Mutable allocator state is behind `inner`; charges use atomics. unsafe impl Send for GuestHeap {} unsafe impl Sync for GuestHeap {} @@ -209,48 +214,67 @@ impl GuestHeap { store: &mut StoreMut<'_>, memory: &wasmer::Memory, budget: Arc, - ) -> Option> { + ) -> Option<(Arc, u64)> { let base = memory.view(store).data_ptr() as usize; if base == 0 { - return Self::new(store, memory, budget); + return Self::new(store, memory, budget).map(|heap| (heap, 0)); } // Shared memories: one heap per base, reused across threads/envs. // Non-shared memories are single-threaded (conformance lane) — a fresh // heap each time is fine and avoids cross-instance base-key collisions. if !memory.ty(store).shared { - return Self::new(store, memory, budget); + return Self::new(store, memory, budget).map(|heap| (heap, 0)); } let mut reg = shared_registry() .lock() .expect("guest-heap registry poisoned"); if let Some(existing) = reg.get(&base).and_then(Weak::upgrade) { - // This store reaches the heap for the first time; its handle is - // the only one that can grow the memory while it is the store - // being lent. - existing.register_memory(store, memory); - return Some(existing); + match existing.register_memory_if_active(memory, &budget) { + Ok(Some(registration)) => return Some((existing, registration)), + Ok(None) => {} // a message may retain a heap with no live env + Err(()) => return None, + } } let heap = Self::new(store, memory, budget)?; reg.insert(base, Arc::downgrade(&heap)); // Opportunistically drop dead entries so the map cannot grow unbounded // across many short-lived instances that reuse addresses. reg.retain(|_, w| w.strong_count() > 0); - Some(heap) + Some((heap, 0)) + } + + /// Record one environment's store handle; registration is released when + /// that environment drops, even if a queued message retains the heap. + /// The alive check and registration share one lock so a final teardown + /// cannot slip between them. Another budget may never reuse this heap. + fn register_memory_if_active( + &self, + memory: &Memory, + budget: &Arc, + ) -> Result, ()> { + let mut inner = self.inner.lock().expect("guest-heap mutex poisoned"); + if inner.memories.is_empty() { + return Ok(None); + } + if !Arc::ptr_eq(&self.budget, budget) { + return Err(()); + } + let id = inner.next_memory_registration; + inner.next_memory_registration = id.checked_add(1).ok_or(())?; + inner.memories.try_reserve(1).map_err(|_| ())?; + inner.memories.push((id, memory.clone())); + Ok(Some(id)) } - /// Record the handle `store` uses for this heap's linear memory, if it has - /// not already. Only the handle belonging to the store being lent can grow - /// the memory, so every store that reaches a shared heap contributes one. - fn register_memory(&self, store: &impl AsStoreRef, memory: &Memory) { + pub(crate) fn unregister_memory(&self, registration: u64) { let mut inner = self.inner.lock().expect("guest-heap mutex poisoned"); - if inner + if let Some(index) = inner .memories .iter() - .any(|known| known.is_from_store(store)) + .position(|(id, _)| *id == registration) { - return; + inner.memories.swap_remove(index); } - inner.memories.push(memory.clone()); } /// Build a fresh heap over the instance's imported memory. Prefer @@ -289,7 +313,8 @@ impl GuestHeap { budget, charged: AtomicU64::new(0), inner: Mutex::new(HeapInner { - memories: vec![memory.clone()], + memories: vec![(0, memory.clone())], + next_memory_registration: 1, chunks: Vec::new(), live: HashMap::new(), free_bytes: 0, @@ -332,8 +357,10 @@ impl GuestHeap { } pub(crate) fn offset_to_host(&self, offset: u32) -> *mut u8 { - // SAFETY: callers only pass offsets inside the (reserved) range. - unsafe { self.base.add(offset as usize) } + // Address arithmetic alone is valid even when a message has retained + // the heap after its nonshared mapping was torn down. Callers may use + // the result only while a live memory registration owns the mapping. + self.base.wrapping_add(offset as usize) } /// Allocate `len` bytes of guest memory, 16-byte aligned. Returns the @@ -347,6 +374,9 @@ impl GuestHeap { let units = u32::try_from(len.max(1).div_ceil(UNIT as usize)).ok()?; let mut inner = self.inner.lock().expect("guest-heap mutex poisoned"); + if inner.memories.is_empty() { + return None; + } let offset = match Self::try_chunks(&mut inner, units) { Some(offset) => offset, None => { @@ -382,6 +412,13 @@ impl GuestHeap { let units = u32::try_from(len.max(1).div_ceil(UNIT as usize)).ok()?; let mut inner = self.inner.lock().expect("guest-heap mutex poisoned"); + if !inner + .memories + .iter() + .any(|(_, memory)| memory.is_from_store(store)) + { + return None; + } let offset = if let Some(offset) = Self::try_chunks(&mut inner, units) { offset } else { @@ -463,8 +500,8 @@ impl GuestHeap { let Some(memory) = inner .memories .iter() - .find(|memory| memory.is_from_store(store)) - .cloned() + .find(|(_, memory)| memory.is_from_store(store)) + .map(|(_, memory)| memory.clone()) else { // This store has no handle on the heap's memory, so it is not the // store that owns it. @@ -653,25 +690,23 @@ impl GuestHeap { /// The receiver takes ownership (released via /// [`napi_host_guest_heap_release`]). pub(crate) fn make_alloc_ctx(self: &Arc) -> *mut c_void { - let p = Box::into_raw(Box::new(GuestHeapCtx { + Box::into_raw(Box::new(GuestHeapCtx { base: self.base as usize, len: self.max_bytes, heap: Arc::downgrade(self), })) - .cast(); - p + .cast() } - /// Box a finalizer context for one allocation. The bridge takes ownership - /// on success (released via [`napi_host_guest_heap_buffer_finalize`]); - /// reclaim with [`GuestHeap::reclaim_finalize_ctx`] on failure. + /// Box a finalizer context for one allocation. The backing-store deleter + /// takes ownership when native creation reaches that point, even if a + /// later step fails. Reclaim only when the bridge reports no transfer. pub(crate) fn make_finalize_ctx(self: &Arc, offset: u32) -> *mut c_void { - let p = Box::into_raw(Box::new(GuestHeapFinalizeCtx { + Box::into_raw(Box::new(GuestHeapFinalizeCtx { heap: Arc::downgrade(self), offset, })) - .cast(); - p + .cast() } /// Take back a finalize ctx that was never handed to a live buffer. @@ -777,6 +812,31 @@ mod tests { (memory, heap) } + #[test] + fn message_retained_heap_is_not_reused_after_its_last_env_detaches() { + let budget = ResourceBudget::unlimited(); + let mut store = Store::default(); + let memory = Memory::new( + &mut store, + MemoryType::new(Pages(1), Some(Pages(1024)), true), + ) + .unwrap(); + let (old, registration) = + GuestHeap::get_or_create(&mut store.as_store_mut(), &memory, Arc::clone(&budget)) + .unwrap(); + let other_budget = ResourceBudget::unlimited(); + assert!( + GuestHeap::get_or_create(&mut store.as_store_mut(), &memory, other_budget).is_none(), + "a different workload must not reuse this heap's accounting" + ); + old.unregister_memory(registration); + assert!(old.alloc(8, true).is_none()); + let (fresh, registration) = + GuestHeap::get_or_create(&mut store.as_store_mut(), &memory, budget).unwrap(); + assert!(!Arc::ptr_eq(&old, &fresh)); + fresh.unregister_memory(registration); + } + #[test] fn chunk_node_budget_scales_with_chunk_size() { // What this pins: `OffsetAllocator::new` sizes every chunk for 128Ki diff --git a/src/lane.rs b/src/lane.rs new file mode 100644 index 00000000..d387dae0 --- /dev/null +++ b/src/lane.rs @@ -0,0 +1,382 @@ +//! Opaque V8 background task queue for an embedder-managed execution lane. + +use std::{cell::RefCell, ffi::c_void, ptr::NonNull, sync::Arc}; + +use anyhow::{Result, bail}; + +/// Called for each actual V8 background task. Dropping the returned guard +/// ends that task's metered active interval. +pub type BackgroundTaskScope = Arc Box + Send + Sync>; + +/// Called by the provider on the first guest environment creation. The +/// embedder owns lazy admission, worker startup, stop, and completion. +pub type ManagedV8LaneActivator = Arc Result> + Send + Sync>; + +unsafe extern "C" { + fn snapi_v8_lane_new( + max_queued_tasks: usize, + scope_context: *mut c_void, + enter_scope: unsafe extern "C" fn(*mut c_void) -> *mut c_void, + leave_scope: unsafe extern "C" fn(*mut c_void, *mut c_void) -> bool, + on_overload: unsafe extern "C" fn(*mut c_void), + ) -> *mut c_void; + fn snapi_v8_lane_run(handle: *mut c_void); + fn snapi_v8_lane_stop(handle: *mut c_void); + fn snapi_v8_lane_delete(handle: *mut c_void); + fn snapi_v8_lane_swap_current(handle: *mut c_void) -> *mut c_void; +} + +pub struct ManagedV8Lane { + handle: NonNull, + _callbacks: Box, +} + +struct LaneCallbacks { + task_scope: BackgroundTaskScope, + on_overload: Arc, +} + +// The C++ lane synchronizes all queue access. Its handle remains allocated +// while either the context or its dedicated worker owns this Arc. +unsafe impl Send for ManagedV8Lane {} +unsafe impl Sync for ManagedV8Lane {} + +impl std::fmt::Debug for ManagedV8Lane { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ManagedV8Lane").finish_non_exhaustive() + } +} + +impl ManagedV8Lane { + /// Allocate only the provider queue adapter. The embedder must first + /// reserve its memory charge and native-thread admission. + pub fn new( + max_queued_tasks: usize, + task_scope: BackgroundTaskScope, + on_overload: Arc, + ) -> Result> { + let callbacks = Box::new(LaneCallbacks { + task_scope, + on_overload, + }); + let handle = NonNull::new(unsafe { + snapi_v8_lane_new( + max_queued_tasks, + (&*callbacks as *const LaneCallbacks).cast_mut().cast(), + enter_task_scope, + leave_task_scope, + signal_overload, + ) + }); + let Some(handle) = handle else { + bail!("failed to allocate V8 background lane"); + }; + Ok(Arc::new(Self { + handle, + _callbacks: callbacks, + })) + } + + /// Close queue admission and wake the worker. Idempotent. + pub fn stop(&self) { + unsafe { snapi_v8_lane_stop(self.handle.as_ptr()) } + } + + /// Run the queue on the embedder's dedicated, instance-accounted thread. + /// Returns once `stop` is called and the current task finishes. + pub fn run(&self) { + unsafe { snapi_v8_lane_run(self.handle.as_ptr()) } + } + + /// Bind this queue to the calling thread while V8 creates or enters an + /// isolate. The guard restores the previous binding on drop. + pub(crate) fn enter(self: &Arc) -> ManagedV8LaneScope { + let id = CURRENT_LANES.with(|scopes| { + let mut scopes = scopes.borrow_mut(); + let previous = unsafe { snapi_v8_lane_swap_current(self.handle.as_ptr()) }; + if scopes.entries.is_empty() { + scopes.base = previous; + } + let id = scopes.next_id; + scopes.next_id = scopes + .next_id + .checked_add(1) + .expect("V8 lane scope ID overflow"); + scopes.entries.push((id, Arc::clone(self))); + id + }); + ManagedV8LaneScope { + id, + _lane: Arc::clone(self), + _thread_bound: std::marker::PhantomData, + } + } +} + +impl Drop for ManagedV8Lane { + fn drop(&mut self) { + unsafe { + snapi_v8_lane_stop(self.handle.as_ptr()); + snapi_v8_lane_delete(self.handle.as_ptr()); + } + } +} + +unsafe extern "C" fn enter_task_scope(context: *mut c_void) -> *mut c_void { + if context.is_null() { + return std::ptr::null_mut(); + } + let callbacks = unsafe { &*context.cast::() }; + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (callbacks.task_scope)())) { + Ok(guard) => Box::into_raw(Box::new(guard)).cast(), + Err(_) => std::ptr::null_mut(), + } +} + +unsafe extern "C" fn signal_overload(context: *mut c_void) { + if context.is_null() { + return; + } + let callbacks = unsafe { &*context.cast::() }; + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + (callbacks.on_overload)(); + })); +} + +unsafe extern "C" fn leave_task_scope(_context: *mut c_void, scope: *mut c_void) -> bool { + if scope.is_null() { + return false; + } + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + drop(unsafe { Box::from_raw(scope.cast::>()) }); + })) + .is_ok() +} + +pub(crate) struct ManagedV8LaneScope { + id: u64, + _lane: Arc, + // Restoring a V8 platform binding is meaningful only on the thread that + // entered it. Keep this scope statically non-Send even if pointer auto + // traits change in a future compiler. + _thread_bound: std::marker::PhantomData>, +} + +#[derive(Default)] +struct ThreadLaneScopes { + next_id: u64, + base: *mut c_void, + entries: Vec<(u64, Arc)>, +} + +thread_local! { + // The Arc entries keep every previous lane alive while a nested scope can + // restore it. Removing a non-top scope must not change the active binding. + static CURRENT_LANES: RefCell = RefCell::new(ThreadLaneScopes::default()); +} + +impl Drop for ManagedV8LaneScope { + fn drop(&mut self) { + let _ = CURRENT_LANES.try_with(|scopes| { + let mut scopes = scopes.borrow_mut(); + let Some(index) = scopes.entries.iter().position(|(id, _)| *id == self.id) else { + return; + }; + let was_top = index + 1 == scopes.entries.len(); + let removed = scopes.entries.remove(index); + if was_top { + let previous = scopes + .entries + .last() + .map_or(scopes.base, |(_, lane)| lane.handle.as_ptr()); + unsafe { snapi_v8_lane_swap_current(previous) }; + } + if scopes.entries.is_empty() { + scopes.base = std::ptr::null_mut(); + } + drop(removed); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Condvar, Mutex, + atomic::{AtomicUsize, Ordering}, + mpsc, + }; + use std::thread; + use std::time::Duration; + + unsafe extern "C" { + fn snapi_v8_lane_post_test_task( + handle: *mut c_void, + callback: unsafe extern "C" fn(*mut c_void), + data: *mut c_void, + ) -> bool; + } + + struct TestTask { + started: mpsc::Sender<()>, + release: Option, Condvar)>>, + } + + unsafe extern "C" fn run_test_task(data: *mut c_void) { + let task = unsafe { Box::from_raw(data.cast::()) }; + let _ = task.started.send(()); + if let Some(release) = &task.release { + let (lock, changed) = &**release; + let mut ready = lock.lock().unwrap(); + while !*ready { + ready = changed.wait(ready).unwrap(); + } + } + } + + unsafe extern "C" fn run_noop_task(_data: *mut c_void) {} + + #[test] + fn zero_queue_capacity_is_rejected() { + assert!(ManagedV8Lane::new(0, Arc::new(|| Box::new(())), Arc::new(|| {})).is_err()); + } + + #[test] + fn dropping_nested_scopes_out_of_order_never_restores_a_released_lane() { + let first = ManagedV8Lane::new(1, Arc::new(|| Box::new(())), Arc::new(|| {})).unwrap(); + let second = ManagedV8Lane::new(1, Arc::new(|| Box::new(())), Arc::new(|| {})).unwrap(); + let first_scope = first.enter(); + let second_scope = second.enter(); + drop(first_scope); + drop(first); + let active = unsafe { snapi_v8_lane_swap_current(std::ptr::null_mut()) }; + assert_eq!(active, second.handle.as_ptr()); + unsafe { snapi_v8_lane_swap_current(active) }; + drop(second_scope); + let active = unsafe { snapi_v8_lane_swap_current(std::ptr::null_mut()) }; + assert!(active.is_null()); + } + + fn post(lane: &ManagedV8Lane, task: TestTask) { + let data = Box::into_raw(Box::new(task)); + if !unsafe { + snapi_v8_lane_post_test_task(lane.handle.as_ptr(), run_test_task, data.cast()) + } { + drop(unsafe { Box::from_raw(data) }); + panic!("task admission rejected"); + } + } + + #[test] + fn blocked_lane_does_not_stall_another_and_scopes_are_balanced() { + let entered = Arc::new(AtomicUsize::new(0)); + let exited = Arc::new(AtomicUsize::new(0)); + struct Guard(Arc); + impl Drop for Guard { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } + } + let make_lane = || { + let scope: BackgroundTaskScope = { + let entered = Arc::clone(&entered); + let exited = Arc::clone(&exited); + Arc::new(move || { + entered.fetch_add(1, Ordering::SeqCst); + Box::new(Guard(Arc::clone(&exited))) + }) + }; + ManagedV8Lane::new(256, scope, Arc::new(|| {})).unwrap() + }; + let first = make_lane(); + let second = make_lane(); + let first_worker = { + let lane = Arc::clone(&first); + thread::spawn(move || lane.run()) + }; + let second_worker = { + let lane = Arc::clone(&second); + thread::spawn(move || lane.run()) + }; + let release = Arc::new((Mutex::new(false), Condvar::new())); + let (first_tx, first_rx) = mpsc::channel(); + post( + &first, + TestTask { + started: first_tx, + release: Some(Arc::clone(&release)), + }, + ); + first_rx + .recv_timeout(Duration::from_secs(2)) + .expect("first task starts"); + let (second_tx, second_rx) = mpsc::channel(); + post( + &second, + TestTask { + started: second_tx, + release: None, + }, + ); + second_rx + .recv_timeout(Duration::from_secs(2)) + .expect("second instance progresses"); + first.stop(); + second.stop(); + { + let (lock, changed) = &*release; + *lock.lock().unwrap() = true; + changed.notify_all(); + } + first_worker.join().unwrap(); + second_worker.join().unwrap(); + assert_eq!(entered.load(Ordering::SeqCst), 2); + assert_eq!(exited.load(Ordering::SeqCst), 2); + } + + #[test] + fn queue_overload_signals_embedder_before_blocked_task_returns() { + let overloads = Arc::new(AtomicUsize::new(0)); + let lane = ManagedV8Lane::new(256, Arc::new(|| Box::new(())), { + let overloads = Arc::clone(&overloads); + Arc::new(move || { + overloads.fetch_add(1, Ordering::SeqCst); + }) + }) + .unwrap(); + let worker = { + let lane = Arc::clone(&lane); + thread::spawn(move || lane.run()) + }; + let release = Arc::new((Mutex::new(false), Condvar::new())); + let (started_tx, started_rx) = mpsc::channel(); + post( + &lane, + TestTask { + started: started_tx, + release: Some(Arc::clone(&release)), + }, + ); + started_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + for _ in 0..256 { + assert!(unsafe { + snapi_v8_lane_post_test_task( + lane.handle.as_ptr(), + run_noop_task, + std::ptr::null_mut(), + ) + }); + } + assert!(!unsafe { + snapi_v8_lane_post_test_task(lane.handle.as_ptr(), run_noop_task, std::ptr::null_mut()) + }); + assert_eq!(overloads.load(Ordering::SeqCst), 1); + { + let (lock, changed) = &*release; + *lock.lock().unwrap() = true; + changed.notify_all(); + } + worker.join().unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs index ba88a953..713dc199 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,9 @@ mod guest; #[cfg(not(all(target_arch = "wasm32", feature = "js")))] mod guest_heap; mod id_hash; +#[cfg(not(all(target_arch = "wasm32", feature = "js")))] +mod lane; +mod message; mod snapi; #[cfg(all(target_arch = "wasm32", feature = "js"))] mod snapi_js; @@ -32,6 +35,8 @@ use enum_iterator::Sequence; pub(crate) use env::NapiEnv; #[cfg(all(target_arch = "wasm32", feature = "js"))] pub(crate) use env::{GuestBackingStoreMapping, HostBufferCopy}; +#[cfg(not(all(target_arch = "wasm32", feature = "js")))] +pub use lane::{BackgroundTaskScope, ManagedV8Lane, ManagedV8LaneActivator}; /// Host capabilities required by the JavaScript-backed N-API implementation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 00000000..77daac98 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,192 @@ +//! Ownership and budget reservations for serialized cross-worker messages. +//! +//! The native bridge uses process-wide numeric handles because the producer +//! and consumer can be different V8 environments. A guest must never use a +//! handle minted by another NapiCtx, even when it guesses its numeric ID. + +use std::{ + collections::HashMap, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; + +use crate::{ + budget::{OverBudget, Pool, ResourceBudget}, + snapi::snapi_bridge_unofficial_message_drop, +}; + +/// Temporary worst-case reservation covering serializer capacity, its object +/// table, and message metadata. The native serializer rejects a buffer above +/// 4 MiB and caps transfer side tables. On success the reservation shrinks to +/// the measured native bytes retained until the receiver consumes the handle. +pub(crate) const SERIALIZATION_RESERVATION: u64 = 32 * 1024 * 1024; + +pub(crate) struct MessageCharge { + budget: Arc, + bytes: u64, + // Native serialized messages can retain SharedArrayBuffer backing in this + // heap's guest memory. Keep its allocation metadata and WasmLinear charge + // alive until the native payload is destroyed. + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + memory: Option>, + // A detached shared handle pins the exact mapping independently of any + // producer Store. Native payloads may retain backing-store pointers until + // release, including after their producing worker has exited. + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + shared_memory: Option, +} + +impl std::fmt::Debug for MessageCharge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MessageCharge") + .field("bytes", &self.bytes) + .finish_non_exhaustive() + } +} + +impl MessageCharge { + pub(crate) fn reserve(budget: Arc, bytes: u64) -> Result { + budget.try_charge(Pool::SerializedMessage, bytes)?; + Ok(Self { + budget, + bytes, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + memory: None, + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + shared_memory: None, + }) + } + + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + pub(crate) fn bind_memory( + &mut self, + memory: Arc, + shared_memory: Option, + ) { + self.memory = Some(memory); + self.shared_memory = shared_memory; + } + + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + fn belongs_to_memory(&self, memory: &Arc) -> bool { + self.memory + .as_ref() + .is_some_and(|bound| Arc::ptr_eq(bound, memory)) + } + + pub(crate) fn shrink(&mut self, retained_bytes: u64) -> bool { + if retained_bytes > self.bytes { + return false; + } + self.budget + .uncharge(Pool::SerializedMessage, self.bytes - retained_bytes); + self.bytes = retained_bytes; + true + } +} + +impl Drop for MessageCharge { + fn drop(&mut self) { + self.budget.uncharge(Pool::SerializedMessage, self.bytes); + } +} + +#[derive(Debug)] +pub(crate) struct PendingMessages { + handles: Mutex>, + closed: AtomicBool, +} + +impl PendingMessages { + pub(crate) fn new() -> Arc { + Arc::new(Self { + handles: Mutex::new(HashMap::new()), + closed: AtomicBool::new(false), + }) + } + + /// Takes ownership after the bridge has created a message. The caller + /// drops the bridge handle itself if insertion fails. + pub(crate) fn insert(&self, id: u32, charge: MessageCharge) -> Result<(), MessageCharge> { + if id == 0 { + return Err(charge); + } + let mut handles = self.handles.lock().expect("poisoned message registry"); + if self.closed.load(Ordering::Acquire) { + return Err(charge); + } + if handles.contains_key(&id) || handles.try_reserve(1).is_err() { + return Err(charge); + } + handles.insert(id, charge); + Ok(()) + } + + /// An atomic ownership check and removal. The charge remains live in the + /// returned guard until the native take/drop operation has completed. + pub(crate) fn take(&self, id: u32) -> Option { + self.handles + .lock() + .expect("poisoned message registry") + .remove(&id) + } + + /// Only the exact producing guest memory may deserialize backing stores. + /// Leave a mismatched payload queued so its producer can release it. + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + pub(crate) fn take_for_memory( + &self, + id: u32, + memory: &Arc, + ) -> Option { + let mut handles = self.handles.lock().expect("poisoned message registry"); + if !handles.get(&id)?.belongs_to_memory(memory) { + return None; + } + handles.remove(&id) + } + + /// Discard queued payloads once the instance has stopped and guest work + /// has drained. Cloned Wasmer hooks may otherwise retain the context. + pub(crate) fn close_and_clear(&self) { + self.closed.store(true, Ordering::Release); + self.clear(); + } + + fn clear(&self) { + let handles = { + let mut guard = self.handles.lock().expect("poisoned message registry"); + std::mem::take(&mut *guard) + }; + for id in handles.keys().copied() { + unsafe { snapi_bridge_unofficial_message_drop(id) }; + } + } +} + +impl Drop for PendingMessages { + fn drop(&mut self) { + self.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pending_messages_are_context_owned_and_charged() { + let budget = ResourceBudget::with_memory_limit(100); + let first = PendingMessages::new(); + let second = PendingMessages::new(); + let mut charge = MessageCharge::reserve(Arc::clone(&budget), 80).unwrap(); + assert!(charge.shrink(20)); + assert_eq!(budget.snapshot().serialized_message, 20); + first.insert(1, charge).unwrap(); + assert!(second.take(1).is_none()); + drop(first.take(1)); + assert_eq!(budget.snapshot().serialized_message, 0); + } +} diff --git a/src/napi_bridge_init.cc b/src/napi_bridge_init.cc index 4cd95f95..02ab7325 100644 --- a/src/napi_bridge_init.cc +++ b/src/napi_bridge_init.cc @@ -8,12 +8,13 @@ #include #include +#include #include #include #include #include -#include #include +#include #include #include #include @@ -29,6 +30,13 @@ #include "node_api.h" #include "unofficial_napi.h" +// Private V8 provider accounting hook. It is never imported by WASIX guests. +extern "C" size_t unofficial_napi_message_retained_bytes( + unofficial_napi_message message); +// Rust-exported budget hooks (see budget.rs) for the installed tracker. +extern "C" int napi_host_bookkeeping_charge(const void *data, uint64_t bytes); +extern "C" void napi_host_bookkeeping_uncharge(const void *data, uint64_t bytes); + namespace { std::recursive_mutex g_mu; @@ -54,7 +62,7 @@ struct HandleTable { uint32_t next_id = 1; uint32_t Store(void *handle) { - if (handle == nullptr) + if (handle == nullptr || next_id == 0) return 0; const uint32_t id = next_id++; handles[id] = handle; @@ -129,6 +137,9 @@ struct SnapiEnvState { // enter the same environment on the same OS thread. std::recursive_mutex mutex; size_t active_bridge_calls = 0; + // Owned by the Rust NapiCtx while this env is live. EnvLease reinstalls its + // provenance on each bridge entry, including reentrant guest callbacks. + void* background_lane = nullptr; std::mutex control_mutex; std::condition_variable control_cv; size_t active_control_calls = 0; @@ -152,6 +163,7 @@ struct SnapiEnvState { // Handle table for napi_ref (references). std::unordered_map refs; uint32_t next_ref_id = 1; + std::unordered_set guest_finalizer_records; // Handle table for napi_deferred (promise deferreds). std::unordered_map deferreds; @@ -181,10 +193,43 @@ struct SnapiEnvState { uint32_t next_cb_reg_id = 1; std::vector> callback_bindings; - // Cap on live per-value host handles / callback registrations, bounding the - // host-side bookkeeping RSS that the byte budget pools do not otherwise see. - // 0 means unlimited. Set from the resource budget at env creation. - size_t value_limit = 0; + // Host memory this bridge keeps per guest handle (refs, value slots, + // finalizer records, deferreds, scope frames, callback registrations) is + // invisible to V8 and to the embedder's byte pools. It is estimated and + // charged to the embedder's budget through `host_budget` (null for an + // unbudgeted env) in kBookkeepingChunk steps, so a guest retaining many + // host handles hits the same out-of-memory path as one retaining many JS + // objects. `bk_reclaimable` is the part only a GC frees (refs and finalizer + // records die with their JS value); once it doubles since the last + // collection the bridge asks V8 to collect, because a small JS heap alone + // would never trigger the GC that releases it. + const void *host_budget = nullptr; + size_t bk_live = 0; + size_t bk_granted = 0; + size_t bk_reclaimable = 0; + size_t bk_reclaimable_floor = 0; + std::chrono::steady_clock::time_point bk_last_gc{}; + bool bk_collecting = false; +}; + +// Estimated host bytes per bookkeeping entry: the bridge's record and +// container node plus the engine-side object it owns. +constexpr size_t kRefBytes = 192; +constexpr size_t kValueSlotBytes = 32; +constexpr size_t kFinalizerRecordBytes = 96; +constexpr size_t kDeferredBytes = 64; +constexpr size_t kScopeFrameBytes = 128; +constexpr size_t kCallbackBytes = 96; +constexpr size_t kBookkeepingChunk = 64 * 1024; +constexpr size_t kBookkeepingGcMin = 1024 * 1024; +constexpr auto kBookkeepingGcInterval = std::chrono::milliseconds(250); + +struct GuestFinalizerRecord { + SnapiEnvState *state; + uint32_t guest_env; + uint32_t wasm_fn_ptr; + uint32_t data; + uint32_t hint; }; struct CallbackBinding { @@ -196,19 +241,77 @@ struct CallbackBinding { // exit (see the runtime-globals comment in unofficial_napi.cc). std::unordered_map> &g_envs = *new std::unordered_map>(); -// Message resources cross environment boundaries, so their guest-visible IDs -// cannot belong to either the source or destination environment. The table is -// process-wide and each entry is removed atomically by take or drop. +// Message resources cross worker environments. Rust validates each guest ID +// against the owning NapiCtx before calling into this process-wide table. HandleTable &g_message_handles = *new HandleTable(); +// Asks V8 to collect once the GC-reclaimable bookkeeping has doubled since +// the last collection (at least kBookkeepingGcMin, at most once per +// kBookkeepingGcInterval). Runs on the env's JS thread with V8 entered, like +// every bridge call; weak callbacks only enqueue finalizers, so this cannot +// re-enter the guest. +void MaybeCollectBookkeeping(SnapiEnvState &state) { + const size_t threshold = + std::max(kBookkeepingGcMin, 2 * state.bk_reclaimable_floor); + if (state.bk_reclaimable < threshold || state.bk_collecting || + state.env == nullptr) + return; + const auto now = std::chrono::steady_clock::now(); + if (now - state.bk_last_gc < kBookkeepingGcInterval) + return; + state.bk_collecting = true; + state.bk_last_gc = now; + state.bk_reclaimable_floor = state.bk_reclaimable; + (void)unofficial_napi_collect_garbage(state.env); + state.bk_collecting = false; +} + +// Charges `bytes` of host bookkeeping, asking the embedder for another chunk +// when the live estimate outgrows what it granted. Returns false when the +// budget refused; the embedder has then already requested termination and the +// caller fails the operation. +bool ChargeBookkeeping(SnapiEnvState &state, size_t bytes, bool reclaimable) { + if (state.bk_live + bytes > state.bk_granted) { + const size_t needed = state.bk_live + bytes - state.bk_granted; + const size_t chunk = + (needed + kBookkeepingChunk - 1) / kBookkeepingChunk * kBookkeepingChunk; + if (state.host_budget != nullptr && + !napi_host_bookkeeping_charge(state.host_budget, chunk)) + return false; + state.bk_granted += chunk; + } + state.bk_live += bytes; + if (reclaimable) { + state.bk_reclaimable += bytes; + MaybeCollectBookkeeping(state); + } + return true; +} + +// Returns whole unused chunks to the embedder, keeping one in hand so a +// workload oscillating around a chunk boundary does not charge on every call. +void UnchargeBookkeeping(SnapiEnvState &state, size_t bytes, + bool reclaimable) { + state.bk_live -= bytes; + if (reclaimable) { + state.bk_reclaimable -= bytes; + state.bk_reclaimable_floor = + std::min(state.bk_reclaimable_floor, state.bk_reclaimable); + } + const size_t unused = state.bk_granted - state.bk_live; + if (unused < 2 * kBookkeepingChunk) + return; + const size_t release = + (unused - kBookkeepingChunk) / kBookkeepingChunk * kBookkeepingChunk; + state.bk_granted -= release; + if (state.host_budget != nullptr) + napi_host_bookkeeping_uncharge(state.host_budget, release); +} + CallbackBinding *RegisterCallbackBinding(SnapiEnvState *state, uint32_t reg_id) { if (state == nullptr || reg_id == 0) return nullptr; - if (state->value_limit != 0 && - state->callback_bindings.size() >= state->value_limit) { - return nullptr; - } state->callback_bindings.push_back( std::make_unique(CallbackBinding{state, reg_id})); return state->callback_bindings.back().get(); @@ -233,23 +336,31 @@ struct EnvLease { : std::unique_lock()) { if (state != nullptr) { ++state->active_bridge_calls; - // The calling worker may predate V8's allocation of its Linux JIT - // protection key. Restore V8's default read-only permissions before - // accessing isolate-owned memory on this thread. + // WASIX workers may predate V8's Linux protection key allocation. + // Re-enable only V8's default read-only access on this thread before + // touching any isolate-owned memory. v8::ThreadIsolatedAllocator::SetDefaultPermissionsForSignalHandler(); + previous_lane = snapi_v8_lane_swap_current(state->background_lane); + lane_bound = true; } } ~EnvLease() { if (state != nullptr) --state->active_bridge_calls; + if (lane_bound) snapi_v8_lane_swap_current(previous_lane); } + EnvLease(const EnvLease&) = delete; + EnvLease& operator=(const EnvLease&) = delete; + SnapiEnvState *get() const { return state != nullptr && state->env != nullptr ? state.get() : nullptr; } std::shared_ptr state; std::unique_lock lock; + void* previous_lane = nullptr; + bool lane_bound = false; }; uint32_t RegisterCallbackInvocation(SnapiEnvState *state, @@ -279,18 +390,17 @@ uint32_t StoreValueInFrame(SnapiEnvState &state, napi_value val, return 0; if (state.env == nullptr) return 0; - // Refuse once the per-env handle cap is reached: returning 0 surfaces as an - // N-API failure the guest can handle, instead of leaking host RSS unbounded. - if (state.value_limit != 0 && state.live_value_count >= state.value_limit) { + // Returning 0 surfaces as an N-API failure the guest can handle. + if (state.value_free_slots.empty() && + state.value_slots.size() >= kValueIdMaxSlots) + return 0; + if (!ChargeBookkeeping(state, kValueSlotBytes, /*reclaimable=*/false)) return 0; - } uint32_t index; if (!state.value_free_slots.empty()) { index = state.value_free_slots.back(); state.value_free_slots.pop_back(); } else { - if (state.value_slots.size() >= kValueIdMaxSlots) - return 0; index = static_cast(state.value_slots.size()); state.value_slots.emplace_back(); } @@ -336,6 +446,7 @@ void FreeValueSlot(SnapiEnvState &state, uint32_t index) { static_cast((slot.generation + 1) & kValueIdGenMask); state.value_free_slots.push_back(index); --state.live_value_count; + UnchargeBookkeeping(state, kValueSlotBytes, /*reclaimable=*/false); } // Frees every slot the innermost frame owns and pops it. Closes the frame's @@ -349,6 +460,10 @@ void PopCurrentFrame(SnapiEnvState &state, bool close_napi_scope) { for (uint32_t index : frame.owned_slots) { FreeValueSlot(state, index); } + // Only guest-opened frames are charged; implicit callback frames are + // bounded by the callback depth. + if (frame.id != 0) + UnchargeBookkeeping(state, kScopeFrameBytes, /*reclaimable=*/false); if (close_napi_scope && state.env != nullptr) { if (frame.esc_scope != nullptr) { (void)napi_close_escapable_handle_scope(state.env, frame.esc_scope); @@ -414,9 +529,18 @@ size_t TypedArrayElementSize(napi_typedarray_type type) { return 0; } +// Charges one more napi_ref before the engine creates it. Pair with StoreRef, +// or release it with UnchargeBookkeeping(kRefBytes) if creation fails. +bool ReserveRef(SnapiEnvState &state) { + return state.next_ref_id != 0 && + ChargeBookkeeping(state, kRefBytes, /*reclaimable=*/true); +} + uint32_t StoreRef(SnapiEnvState &state, napi_ref ref) { - if (ref == nullptr) + if (ref == nullptr) { + UnchargeBookkeeping(state, kRefBytes, /*reclaimable=*/true); return 0; + } uint32_t id = state.next_ref_id++; state.refs[id] = ref; return id; @@ -429,11 +553,17 @@ napi_ref LoadRef(SnapiEnvState &state, uint32_t id) { return it != state.refs.end() ? it->second : nullptr; } -void RemoveRef(SnapiEnvState &state, uint32_t id) { state.refs.erase(id); } +void RemoveRef(SnapiEnvState &state, uint32_t id) { + if (state.refs.erase(id) != 0) + UnchargeBookkeeping(state, kRefBytes, /*reclaimable=*/true); +} +// The caller has charged kDeferredBytes (see snapi_bridge_create_promise). uint32_t StoreDeferred(SnapiEnvState &state, napi_deferred d) { - if (d == nullptr) + if (d == nullptr) { + UnchargeBookkeeping(state, kDeferredBytes, /*reclaimable=*/false); return 0; + } uint32_t id = state.next_deferred_id++; state.deferreds[id] = d; return id; @@ -447,7 +577,8 @@ napi_deferred LoadDeferred(SnapiEnvState &state, uint32_t id) { } void RemoveDeferred(SnapiEnvState &state, uint32_t id) { - state.deferreds.erase(id); + if (state.deferreds.erase(id) != 0) + UnchargeBookkeeping(state, kDeferredBytes, /*reclaimable=*/false); } // Finds the open frame with the given guest-visible id (innermost first). @@ -496,8 +627,9 @@ EnvLease RequireEnvState(SnapiEnvState *env_state) { napi_status DisposeBridgeStateLocked(SnapiEnvState *state) { if (state == nullptr) return napi_ok; - // Releasing an environment from its own callback would dispose an entered - // isolate. Keep all ownership and registry state intact on this refusal. + // The release import itself owns one lease. A second one means this call + // reentered from an active N-API operation and disposing the entered V8 + // isolate would leave its outer stack with freed native state. if (state->active_bridge_calls != 1 || !state->callback_invocations.empty()) return napi_generic_failure; // Stop admitting control calls. Keep the state in the registry through V8 @@ -509,6 +641,12 @@ napi_status DisposeBridgeStateLocked(SnapiEnvState *state) { state->control_cv.wait(control_lock, [&] { return state->active_control_calls == 0; }); } + // Env destruction runs native finalizers. Never let those callbacks reenter + // guest Wasm or JS: Rust has already removed this env from the host kill + // registry, and provider teardown clears its heap-limit hook before the + // finalizer drain. A finalizer that executes JS here would be unmetered and + // impossible for the workload manager to interrupt safely. + state->active_callback_ctx.store(nullptr, std::memory_order_release); for (const auto &entry : state->buffer_lease_handles.handles) { (void)unofficial_napi_release_buffer_lease( state->env, static_cast(entry.second), @@ -528,6 +666,12 @@ napi_status DisposeBridgeStateLocked(SnapiEnvState *state) { state->next_ref_id = 1; state->deferreds.clear(); state->next_deferred_id = 1; + for (const auto &entry : state->module_wrap_handles.handles) { + if (entry.second != nullptr) { + (void)unofficial_napi_module_wrap_destroy( + state->env, reinterpret_cast(entry.second)); + } + } state->module_wrap_handles.Reset(); // Bytecode handles own engine resources outside any napi scope (V8 // Globals / QuickJS JSValue refcounts), so they must be released @@ -549,9 +693,19 @@ napi_status DisposeBridgeStateLocked(SnapiEnvState *state) { if (state->owner != nullptr) { release_status = unofficial_napi_release_env(state->owner, nullptr); } + // remove_wrap/delete_reference can cancel provider finalizers without + // calling our trampoline. Reclaim their bridge records after the provider + // has finished draining every callback for this environment. + for (void *record : state->guest_finalizer_records) + delete static_cast(record); + state->guest_finalizer_records.clear(); state->active_callback_ctx.store(nullptr, std::memory_order_release); state->owner = nullptr; state->env = nullptr; + // The embedder releases everything it granted when it drops the tracker. + state->host_budget = nullptr; + state->bk_live = state->bk_granted = state->bk_reclaimable = 0; + state->bk_reclaimable_floor = 0; { std::lock_guard registry_lock(g_mu); g_envs.erase(state); @@ -1766,11 +1920,16 @@ extern "C" int snapi_bridge_create_promise(SnapiEnvState *env_state, if (bridge_state == nullptr) return napi_invalid_arg; napi_env env = bridge_state->env; + if (bridge_state->next_deferred_id == 0 || + !ChargeBookkeeping(*bridge_state, kDeferredBytes, /*reclaimable=*/false)) + return napi_generic_failure; napi_deferred deferred; napi_value promise; napi_status s = napi_create_promise(env, &deferred, &promise); - if (s != napi_ok) + if (s != napi_ok) { + UnchargeBookkeeping(*bridge_state, kDeferredBytes, /*reclaimable=*/false); return s; + } *deferred_out = StoreDeferred(*bridge_state, deferred); *out_id = StoreValue(*bridge_state, promise); return napi_ok; @@ -1867,14 +2026,6 @@ namespace { // backing-store deleter for the guest finalizer — those run synchronously in // GC, with no way back into the guest — they attach via napi_add_finalizer // instead.) Allocated on registration, freed exactly once when it fires. -struct GuestFinalizerRecord { - SnapiEnvState *state; - uint32_t guest_env; - uint32_t wasm_fn_ptr; - uint32_t data; - uint32_t hint; -}; - void GuestFinalizerTrampoline(node_api_basic_env /*env*/, void * /*data*/, void *hint) { auto *record = static_cast(hint); @@ -1882,6 +2033,13 @@ void GuestFinalizerTrampoline(node_api_basic_env /*env*/, void * /*data*/, return; auto bridge_state_lease = LookupEnvState(record->state); auto *bridge_state = bridge_state_lease.get(); + std::unique_lock state_lock; + if (bridge_state != nullptr) { + state_lock = std::unique_lock(bridge_state->mutex); + if (bridge_state->guest_finalizer_records.erase(record) != 0) + UnchargeBookkeeping(*bridge_state, kFinalizerRecordBytes, + /*reclaimable=*/true); + } void *callback_ctx = bridge_state != nullptr ? bridge_state->active_callback_ctx.load(std::memory_order_acquire) @@ -1916,18 +2074,35 @@ void GuestFinalizerTrampoline(node_api_basic_env /*env*/, void * /*data*/, delete record; } +void DropGuestFinalizerRecord(GuestFinalizerRecord *record) { + if (record == nullptr) return; + auto *state = record->state; + if (state != nullptr) { + std::lock_guard lock(state->mutex); + if (state->guest_finalizer_records.erase(record) != 0) + UnchargeBookkeeping(*state, kFinalizerRecordBytes, /*reclaimable=*/true); + } + delete record; +} + GuestFinalizerRecord *MakeGuestFinalizerRecord(SnapiEnvState *state, uint32_t guest_env, uint32_t wasm_fn_ptr, uint32_t data, uint32_t hint) { + if (state == nullptr || + !ChargeBookkeeping(*state, kFinalizerRecordBytes, /*reclaimable=*/true)) + return nullptr; auto *record = new (std::nothrow) GuestFinalizerRecord(); - if (record != nullptr) { - record->state = state; - record->guest_env = guest_env; - record->wasm_fn_ptr = wasm_fn_ptr; - record->data = data; - record->hint = hint; + if (record == nullptr) { + UnchargeBookkeeping(*state, kFinalizerRecordBytes, /*reclaimable=*/true); + return nullptr; } + record->state = state; + record->guest_env = guest_env; + record->wasm_fn_ptr = wasm_fn_ptr; + record->data = data; + record->hint = hint; + state->guest_finalizer_records.insert(record); return record; } } // namespace @@ -1958,7 +2133,7 @@ extern "C" int snapi_bridge_create_external_arraybuffer_guest_finalized( s = napi_add_finalizer(env, result, nullptr, GuestFinalizerTrampoline, record, nullptr); if (s != napi_ok) { - delete record; + DropGuestFinalizerRecord(record); return s; } if (backing_store_token_out) { @@ -1993,7 +2168,7 @@ extern "C" int snapi_bridge_create_external_buffer_guest_finalized( s = napi_add_finalizer(env, result, nullptr, GuestFinalizerTrampoline, record, nullptr); if (s != napi_ok) { - delete record; + DropGuestFinalizerRecord(record); return s; } if (backing_store_token_out) { @@ -2436,10 +2611,14 @@ extern "C" int snapi_bridge_create_reference(SnapiEnvState *env_state, napi_value val = LoadValue(*bridge_state, value_id); if (!val) return napi_invalid_arg; + if (!ReserveRef(*bridge_state)) + return napi_generic_failure; napi_ref ref; napi_status s = napi_create_reference(env, val, initial_refcount, &ref); - if (s != napi_ok) + if (s != napi_ok) { + UnchargeBookkeeping(*bridge_state, kRefBytes, /*reclaimable=*/true); return s; + } *ref_out = StoreRef(*bridge_state, ref); return napi_ok; } @@ -2517,10 +2696,17 @@ extern "C" int snapi_bridge_open_handle_scope(SnapiEnvState *env_state, return napi_invalid_arg; napi_env env = bridge_state->env; (void)CurrentFrame(*bridge_state); // materialize the root frame first + if (bridge_state->next_scope_id == 0 || + !ChargeBookkeeping(*bridge_state, kScopeFrameBytes, + /*reclaimable=*/false)) + return napi_generic_failure; napi_handle_scope scope = nullptr; napi_status s = napi_open_handle_scope(env, &scope); - if (s != napi_ok) + if (s != napi_ok) { + UnchargeBookkeeping(*bridge_state, kScopeFrameBytes, + /*reclaimable=*/false); return s; + } ScopeFrame frame; frame.scope = scope; frame.id = bridge_state->next_scope_id++; @@ -2559,10 +2745,17 @@ snapi_bridge_open_escapable_handle_scope(SnapiEnvState *env_state, return napi_invalid_arg; napi_env env = bridge_state->env; (void)CurrentFrame(*bridge_state); // materialize the root frame first + if (bridge_state->next_scope_id == 0 || + !ChargeBookkeeping(*bridge_state, kScopeFrameBytes, + /*reclaimable=*/false)) + return napi_generic_failure; napi_escapable_handle_scope scope = nullptr; napi_status s = napi_open_escapable_handle_scope(env, &scope); - if (s != napi_ok) + if (s != napi_ok) { + UnchargeBookkeeping(*bridge_state, kScopeFrameBytes, + /*reclaimable=*/false); return s; + } ScopeFrame frame; frame.esc_scope = scope; frame.id = bridge_state->next_scope_id++; @@ -3031,11 +3224,16 @@ extern "C" int snapi_bridge_wrap(SnapiEnvState *env_state, uint32_t obj_id, napi_value obj = LoadValue(*bridge_state, obj_id); if (!obj) return napi_invalid_arg; + if (ref_out != nullptr && !ReserveRef(*bridge_state)) + return napi_generic_failure; napi_ref ref = nullptr; napi_status s = napi_wrap(env, obj, (void *)(uintptr_t)native_data, nullptr, nullptr, ref_out ? &ref : nullptr); - if (s != napi_ok) + if (s != napi_ok) { + if (ref_out != nullptr) + UnchargeBookkeeping(*bridge_state, kRefBytes, /*reclaimable=*/true); return s; + } if (ref_out) *ref_out = StoreRef(*bridge_state, ref); return napi_ok; @@ -3057,16 +3255,20 @@ snapi_bridge_wrap_finalized(SnapiEnvState *env_state, uint32_t obj_id, napi_value obj = LoadValue(*bridge_state, obj_id); if (!obj) return napi_invalid_arg; + if (ref_out != nullptr && !ReserveRef(*bridge_state)) + return napi_generic_failure; auto *record = MakeGuestFinalizerRecord(env_state, guest_env, wasm_fn_ptr, finalize_data, finalize_hint); - if (record == nullptr) - return napi_generic_failure; napi_ref ref = nullptr; napi_status s = - napi_wrap(env, obj, (void *)(uintptr_t)native_data, - GuestFinalizerTrampoline, record, ref_out ? &ref : nullptr); + record == nullptr + ? napi_generic_failure + : napi_wrap(env, obj, (void *)(uintptr_t)native_data, + GuestFinalizerTrampoline, record, ref_out ? &ref : nullptr); if (s != napi_ok) { - delete record; + DropGuestFinalizerRecord(record); + if (ref_out != nullptr) + UnchargeBookkeeping(*bridge_state, kRefBytes, /*reclaimable=*/true); return s; } if (ref_out) @@ -3123,13 +3325,18 @@ extern "C" int snapi_bridge_add_finalizer(SnapiEnvState *env_state, napi_value obj = LoadValue(*bridge_state, obj_id); if (!obj) return napi_invalid_arg; + if (ref_out != nullptr && !ReserveRef(*bridge_state)) + return napi_generic_failure; // No actual WASM callback for finalizer; just register with nullptr callback napi_ref ref = nullptr; napi_status s = napi_add_finalizer(env, obj, (void *)(uintptr_t)data_val, nullptr, nullptr, ref_out ? &ref : nullptr); - if (s != napi_ok) + if (s != napi_ok) { + if (ref_out != nullptr) + UnchargeBookkeeping(*bridge_state, kRefBytes, /*reclaimable=*/true); return s; + } if (ref_out) *ref_out = StoreRef(*bridge_state, ref); return napi_ok; @@ -3150,16 +3357,21 @@ snapi_bridge_add_finalizer_cb(SnapiEnvState *env_state, uint32_t obj_id, napi_value obj = LoadValue(*bridge_state, obj_id); if (!obj) return napi_invalid_arg; + if (ref_out != nullptr && !ReserveRef(*bridge_state)) + return napi_generic_failure; auto *record = MakeGuestFinalizerRecord(env_state, guest_env, wasm_fn_ptr, finalize_data, finalize_hint); - if (record == nullptr) - return napi_generic_failure; napi_ref ref = nullptr; - napi_status s = napi_add_finalizer(env, obj, (void *)(uintptr_t)data_val, - GuestFinalizerTrampoline, record, - ref_out ? &ref : nullptr); + napi_status s = + record == nullptr + ? napi_generic_failure + : napi_add_finalizer(env, obj, (void *)(uintptr_t)data_val, + GuestFinalizerTrampoline, record, + ref_out ? &ref : nullptr); if (s != napi_ok) { - delete record; + DropGuestFinalizerRecord(record); + if (ref_out != nullptr) + UnchargeBookkeeping(*bridge_state, kRefBytes, /*reclaimable=*/true); return s; } if (ref_out) @@ -3201,6 +3413,9 @@ extern "C" int snapi_bridge_new_instance(SnapiEnvState *env_state, // napi_define_properties // ============================================================ +static_assert(sizeof(napi_property_descriptor) <= 128, + "update the guest native-descriptor reservation"); + // Forward declaration (defined below in callback system section) static napi_value generic_wasm_callback(napi_env env, napi_callback_info info); @@ -3218,39 +3433,49 @@ extern "C" int snapi_bridge_define_properties( napi_value obj = LoadValue(*bridge_state, obj_id); if (!obj) return napi_invalid_arg; - std::vector descs(prop_count); - for (uint32_t i = 0; i < prop_count; i++) { - memset(&descs[i], 0, sizeof(napi_property_descriptor)); - descs[i].utf8name = utf8names != nullptr ? utf8names[i] : nullptr; - descs[i].name = (name_ids != nullptr && name_ids[i] != 0) - ? LoadValue(*bridge_state, name_ids[i]) - : nullptr; - descs[i].attributes = (napi_property_attributes)attributes[i]; - - switch (prop_types[i]) { - case 0: - descs[i].value = LoadValue(*bridge_state, value_ids[i]); - break; - case 1: - descs[i].method = generic_wasm_callback; - descs[i].data = RegisterCallbackBinding(bridge_state, method_reg_ids[i]); - break; - case 2: - descs[i].getter = generic_wasm_callback; - descs[i].data = RegisterCallbackBinding(bridge_state, getter_reg_ids[i]); - break; - case 3: - descs[i].setter = generic_wasm_callback; - descs[i].data = RegisterCallbackBinding(bridge_state, setter_reg_ids[i]); - break; - case 4: - descs[i].getter = generic_wasm_callback; - descs[i].setter = generic_wasm_callback; - descs[i].data = RegisterCallbackBinding(bridge_state, getter_reg_ids[i]); - break; + if (prop_count > 4096 || + (prop_count != 0 && + (prop_types == nullptr || value_ids == nullptr || + method_reg_ids == nullptr || getter_reg_ids == nullptr || + setter_reg_ids == nullptr || attributes == nullptr))) + return napi_invalid_arg; + try { + std::vector descs(prop_count); + for (uint32_t i = 0; i < prop_count; i++) { + memset(&descs[i], 0, sizeof(napi_property_descriptor)); + descs[i].utf8name = utf8names != nullptr ? utf8names[i] : nullptr; + descs[i].name = (name_ids != nullptr && name_ids[i] != 0) + ? LoadValue(*bridge_state, name_ids[i]) + : nullptr; + descs[i].attributes = (napi_property_attributes)attributes[i]; + + switch (prop_types[i]) { + case 0: + descs[i].value = LoadValue(*bridge_state, value_ids[i]); + break; + case 1: + descs[i].method = generic_wasm_callback; + descs[i].data = RegisterCallbackBinding(bridge_state, method_reg_ids[i]); + break; + case 2: + descs[i].getter = generic_wasm_callback; + descs[i].data = RegisterCallbackBinding(bridge_state, getter_reg_ids[i]); + break; + case 3: + descs[i].setter = generic_wasm_callback; + descs[i].data = RegisterCallbackBinding(bridge_state, setter_reg_ids[i]); + break; + case 4: + descs[i].getter = generic_wasm_callback; + descs[i].setter = generic_wasm_callback; + descs[i].data = RegisterCallbackBinding(bridge_state, getter_reg_ids[i]); + break; + } } + return napi_define_properties(env, obj, prop_count, descs.data()); + } catch (const std::bad_alloc&) { + return napi_generic_failure; } - return napi_define_properties(env, obj, prop_count, descs.data()); } // ============================================================ @@ -3279,57 +3504,67 @@ extern "C" int snapi_bridge_define_class( if (bridge_state == nullptr) return napi_invalid_arg; napi_env env = bridge_state->env; + if (prop_count > 4096 || + (prop_count != 0 && + (prop_types == nullptr || prop_value_ids == nullptr || + prop_method_reg_ids == nullptr || prop_getter_reg_ids == nullptr || + prop_setter_reg_ids == nullptr || prop_attributes == nullptr))) + return napi_invalid_arg; // Build property descriptors - std::vector descs(prop_count); - for (uint32_t i = 0; i < prop_count; i++) { - memset(&descs[i], 0, sizeof(napi_property_descriptor)); - descs[i].utf8name = prop_names != nullptr ? prop_names[i] : nullptr; - descs[i].name = (prop_name_ids != nullptr && prop_name_ids[i] != 0) - ? LoadValue(*bridge_state, prop_name_ids[i]) - : nullptr; - descs[i].attributes = (napi_property_attributes)prop_attributes[i]; - - switch (prop_types[i]) { - case 0: // value - descs[i].value = LoadValue(*bridge_state, prop_value_ids[i]); - break; - case 1: // method - descs[i].method = generic_wasm_callback; - descs[i].data = - RegisterCallbackBinding(bridge_state, prop_method_reg_ids[i]); - break; - case 2: // getter only - descs[i].getter = generic_wasm_callback; - descs[i].data = - RegisterCallbackBinding(bridge_state, prop_getter_reg_ids[i]); - break; - case 3: // setter only - descs[i].setter = generic_wasm_callback; - descs[i].data = - RegisterCallbackBinding(bridge_state, prop_setter_reg_ids[i]); - break; - case 4: // getter + setter - descs[i].getter = generic_wasm_callback; - descs[i].setter = generic_wasm_callback; - descs[i].data = - RegisterCallbackBinding(bridge_state, prop_getter_reg_ids[i]); - // Note: N-API uses the same data pointer for both getter and setter. - // The setter_reg_id is stored in the getter_reg_id for now. - break; + try { + std::vector descs(prop_count); + for (uint32_t i = 0; i < prop_count; i++) { + memset(&descs[i], 0, sizeof(napi_property_descriptor)); + descs[i].utf8name = prop_names != nullptr ? prop_names[i] : nullptr; + descs[i].name = (prop_name_ids != nullptr && prop_name_ids[i] != 0) + ? LoadValue(*bridge_state, prop_name_ids[i]) + : nullptr; + descs[i].attributes = (napi_property_attributes)prop_attributes[i]; + + switch (prop_types[i]) { + case 0: // value + descs[i].value = LoadValue(*bridge_state, prop_value_ids[i]); + break; + case 1: // method + descs[i].method = generic_wasm_callback; + descs[i].data = + RegisterCallbackBinding(bridge_state, prop_method_reg_ids[i]); + break; + case 2: // getter only + descs[i].getter = generic_wasm_callback; + descs[i].data = + RegisterCallbackBinding(bridge_state, prop_getter_reg_ids[i]); + break; + case 3: // setter only + descs[i].setter = generic_wasm_callback; + descs[i].data = + RegisterCallbackBinding(bridge_state, prop_setter_reg_ids[i]); + break; + case 4: // getter + setter + descs[i].getter = generic_wasm_callback; + descs[i].setter = generic_wasm_callback; + descs[i].data = + RegisterCallbackBinding(bridge_state, prop_getter_reg_ids[i]); + // Note: N-API uses the same data pointer for both getter and setter. + // The setter_reg_id is stored in the getter_reg_id for now. + break; + } } - } - napi_value result; - napi_status s = napi_define_class( - env, utf8name, - name_len == 0xFFFFFFFFu ? NAPI_AUTO_LENGTH : (size_t)name_len, - generic_wasm_callback, RegisterCallbackBinding(bridge_state, ctor_reg_id), - prop_count, descs.data(), &result); - if (s != napi_ok) - return s; - *out_id = StoreValue(*bridge_state, result); - return napi_ok; + napi_value result; + napi_status s = napi_define_class( + env, utf8name, + name_len == 0xFFFFFFFFu ? NAPI_AUTO_LENGTH : (size_t)name_len, + generic_wasm_callback, RegisterCallbackBinding(bridge_state, ctor_reg_id), + prop_count, descs.data(), &result); + if (s != napi_ok) + return s; + *out_id = StoreValue(*bridge_state, result); + return napi_ok; + } catch (const std::bad_alloc&) { + return napi_generic_failure; + } } // ============================================================ @@ -3565,15 +3800,15 @@ static napi_value generic_wasm_callback(napi_env env, napi_callback_info info) { // Allocate a registration ID for a new callback extern "C" uint32_t snapi_bridge_alloc_cb_reg_id(SnapiEnvState *env_state) { - if (env_state == nullptr) + auto lease = RequireEnvState(env_state); + auto *state = lease.get(); + if (state == nullptr) return 0; - // Cap callback registrations alongside value handles: 0 signals failure so - // the registration chain aborts instead of growing cb_registry unbounded. - if (env_state->value_limit != 0 && - env_state->cb_registry.size() >= env_state->value_limit) { + // Registrations live until env teardown. 0 signals failure so the + // registration chain aborts instead of growing cb_registry unbounded. + if (!ChargeBookkeeping(*state, kCallbackBytes, /*reclaimable=*/false)) return 0; - } - return env_state->next_cb_reg_id++; + return state->next_cb_reg_id++; } // Register callback data for a registration ID @@ -3653,6 +3888,7 @@ extern "C" int snapi_bridge_unofficial_create_env(int32_t module_api_version, } state->env = env; state->owner = owner; + state->background_lane = snapi_v8_lane_current(); { std::lock_guard registry_lock(g_mu); g_envs.emplace(state.get(), state); @@ -3714,6 +3950,7 @@ extern "C" int snapi_bridge_unofficial_create_env_with_options( } state->env = env; state->owner = owner; + state->background_lane = snapi_v8_lane_current(); { std::lock_guard registry_lock(g_mu); g_envs.emplace(state.get(), state); @@ -3739,13 +3976,17 @@ size_t HostNearHeapLimitTrampoline(napi_env /*env*/, void *data, } } // namespace -extern "C" int snapi_bridge_unofficial_set_host_near_heap_limit_callback( - SnapiEnvState *env_state, const void *data) { +// Installs the embedder's budget tracker: V8 heap growth is charged through +// the near-heap-limit callback and the bridge's own host bookkeeping through +// napi_host_bookkeeping_charge. `data` must outlive the env. +extern "C" int snapi_bridge_unofficial_set_host_budget(SnapiEnvState *env_state, + const void *data) { auto lease = RequireEnvState(env_state); auto *state = lease.get(); if (state == nullptr) { return napi_invalid_arg; } + state->host_budget = data; return unofficial_napi_configure_near_heap_limit_callback( state->env, HostNearHeapLimitTrampoline, const_cast(data), 0); } @@ -3771,22 +4012,18 @@ extern "C" int snapi_bridge_value_id_alive(SnapiEnvState *env_state, return LoadValue(*bridge_state, id) != nullptr ? 1 : 0; } -extern "C" int snapi_bridge_unofficial_set_value_limit(SnapiEnvState *env_state, - uint64_t limit) { - auto lease = RequireEnvState(env_state); - auto *state = lease.get(); - if (state == nullptr) { - return napi_invalid_arg; - } - state->value_limit = static_cast(limit); - return napi_ok; -} - extern "C" int snapi_bridge_unofficial_release_env(SnapiEnvState *env_state) { auto lease = RequireEnvState(env_state); return DisposeBridgeStateLocked(lease.get()); } +// A release may report an error from a guest finalizer after the isolate was +// nevertheless disposed. Query registry membership without dereferencing a +// stale guest handle so Rust can retain quota only for a live native env. +extern "C" int snapi_bridge_unofficial_env_alive(SnapiEnvState *env_state) { + return LookupEnvState(env_state) != nullptr ? 1 : 0; +} + extern "C" int snapi_bridge_unofficial_release_env_with_loop(SnapiEnvState *env_state, uint32_t loop_id) { @@ -4412,9 +4649,9 @@ extern "C" int snapi_bridge_unofficial_structured_clone( return napi_ok; } -extern "C" int snapi_bridge_unofficial_message_create(SnapiEnvState *env_state, - uint32_t value_id, - uint32_t *message_out) { +extern "C" int snapi_bridge_unofficial_message_create_metered( + SnapiEnvState *env_state, uint32_t value_id, uint32_t *message_out, + uint64_t *retained_bytes_out) { auto bridge_state_lease = RequireEnvState(env_state); auto *bridge_state = bridge_state_lease.get(); if (bridge_state == nullptr || message_out == nullptr) @@ -4427,19 +4664,42 @@ extern "C" int snapi_bridge_unofficial_message_create(SnapiEnvState *env_state, unofficial_napi_message_create(bridge_state->env, value, &message); if (status != napi_ok) return status; + // The guest reserved 32 MiB before entering V8. Reject any unexpected + // retained representation that would exceed that reservation. + constexpr size_t kMaxRetainedBytes = 32 * 1024 * 1024; + const size_t payload_bytes = unofficial_napi_message_retained_bytes(message); + if (payload_bytes > kMaxRetainedBytes - 4096) { + unofficial_napi_message_drop(message); + return napi_generic_failure; + } + const size_t retained_bytes = payload_bytes + 4096; uint32_t message_id; { std::lock_guard registry_lock(g_mu); - message_id = g_message_handles.Store(reinterpret_cast(message)); + try { + message_id = g_message_handles.Store(reinterpret_cast(message)); + } catch (const std::bad_alloc &) { + unofficial_napi_message_drop(message); + return napi_generic_failure; + } } if (message_id == 0) { unofficial_napi_message_drop(message); return napi_generic_failure; } *message_out = message_id; + if (retained_bytes_out != nullptr) + *retained_bytes_out = retained_bytes; return napi_ok; } +extern "C" int snapi_bridge_unofficial_message_create(SnapiEnvState *env_state, + uint32_t value_id, + uint32_t *message_out) { + return snapi_bridge_unofficial_message_create_metered(env_state, value_id, + message_out, nullptr); +} + extern "C" int snapi_bridge_unofficial_message_take(SnapiEnvState *env_state, uint32_t message_id, uint32_t *value_out) { @@ -4847,12 +5107,30 @@ extern "C" int snapi_bridge_unofficial_module_wrap_create( } return napi_generic_failure; } - if (handle_out != nullptr) { - *handle_out = StoreModuleWrapHandle(*bridge_state, result.module); + uint32_t handle_id = 0; + uint32_t requests_id = 0; + try { + if (handle_out != nullptr) + handle_id = StoreModuleWrapHandle(*bridge_state, result.module); + if (requests_out != nullptr) + requests_id = StoreValue(*bridge_state, result.module_requests); + } catch (const std::bad_alloc &) { + if (handle_id != 0) + RemoveModuleWrapHandle(*bridge_state, handle_id); + (void)unofficial_napi_module_wrap_destroy(env, result.module); + return napi_generic_failure; } - if (requests_out != nullptr) { - *requests_out = StoreValue(*bridge_state, result.module_requests); + if ((handle_out != nullptr && handle_id == 0) || + (requests_out != nullptr && requests_id == 0)) { + if (handle_id != 0) + RemoveModuleWrapHandle(*bridge_state, handle_id); + (void)unofficial_napi_module_wrap_destroy(env, result.module); + return napi_generic_failure; } + if (handle_out != nullptr) + *handle_out = handle_id; + if (requests_out != nullptr) + *requests_out = requests_id; if (has_top_level_await_out != nullptr) { *has_top_level_await_out = result.has_top_level_await ? 1 : 0; } @@ -4881,6 +5159,10 @@ extern "C" int snapi_bridge_unofficial_module_wrap_link(SnapiEnvState *env_state, uint32_t handle_id, uint32_t count, const uint32_t *linked_handle_ids) { + // Bound this before allocating the bridge vector even for callers that + // bypass the Rust import layer. + if (count > 4096 || (count != 0 && linked_handle_ids == nullptr)) + return napi_invalid_arg; auto bridge_state_lease = RequireEnvState(env_state); auto *bridge_state = bridge_state_lease.get(); if (bridge_state == nullptr) @@ -4890,18 +5172,20 @@ snapi_bridge_unofficial_module_wrap_link(SnapiEnvState *env_state, LoadModuleWrapHandle(*bridge_state, handle_id); if (module == nullptr) return napi_invalid_arg; - std::vector linked_modules(count, nullptr); - for (uint32_t i = 0; i < count; ++i) { - unofficial_napi_module linked = - linked_handle_ids != nullptr - ? LoadModuleWrapHandle(*bridge_state, linked_handle_ids[i]) - : nullptr; - if (linked == nullptr) - return napi_invalid_arg; - linked_modules[i] = linked; + try { + std::vector linked_modules(count, nullptr); + for (uint32_t i = 0; i < count; ++i) { + unofficial_napi_module linked = + LoadModuleWrapHandle(*bridge_state, linked_handle_ids[i]); + if (linked == nullptr) + return napi_invalid_arg; + linked_modules[i] = linked; + } + return unofficial_napi_module_wrap_link( + env, module, count, count == 0 ? nullptr : linked_modules.data()); + } catch (const std::bad_alloc&) { + return napi_generic_failure; } - return unofficial_napi_module_wrap_link( - env, module, count, count == 0 ? nullptr : linked_modules.data()); } extern "C" int diff --git a/src/snapi.rs b/src/snapi.rs index 90def3c7..05665efb 100644 --- a/src/snapi.rs +++ b/src/snapi.rs @@ -50,8 +50,9 @@ pub struct SnapiUnofficialHeapCodeStatistics { pub cpu_profiler_metadata_size: u64, } -// Items allowed to be dead for the JS backend are reached by its guest layer -// through `snapi_js::host_js_*` entry points or direct guest memory access. +// The bridge ABI is shared with the browser-hosted backend and optional +// extension imports, so a native build does not reference every declaration. +#[allow(dead_code)] unsafe extern "C" { pub fn snapi_bridge_init() -> i32; pub fn snapi_bridge_set_v8_worker_thread_count(count: u32) -> i32; @@ -76,6 +77,8 @@ unsafe extern "C" { env_out: *mut SnapiEnv, ) -> i32; pub fn snapi_bridge_unofficial_release_env(env: SnapiEnv) -> i32; + #[cfg(not(all(target_arch = "wasm32", feature = "js")))] + pub fn snapi_bridge_unofficial_env_alive(env: SnapiEnv) -> i32; pub fn snapi_bridge_unofficial_release_env_with_loop(env: SnapiEnv, loop_id: u32) -> i32; pub fn snapi_bridge_unofficial_collect_garbage(env: SnapiEnv) -> i32; pub fn snapi_bridge_unofficial_event_loop_checkpoint( @@ -117,17 +120,10 @@ unsafe extern "C" { after_callback_id: u32, resolve_callback_id: u32, ) -> i32; - /// Register a host-owned near-heap-limit callback that charges V8 heap - /// growth against the resource budget. `data` is an - /// `*const crate::budget::EnvHeapCharge` passed opaquely to the callback. - pub fn snapi_bridge_unofficial_set_host_near_heap_limit_callback( - env: SnapiEnv, - data: *const c_void, - ) -> i32; - /// Cap the number of live per-value host handles (and callback - /// registrations) this env may hold, bounding host-side bookkeeping RSS. - /// `limit == 0` means unlimited. - pub fn snapi_bridge_unofficial_set_value_limit(env: SnapiEnv, limit: u64) -> i32; + /// Install `data` (an `*const crate::budget::EnvHeapCharge`) as this env's + /// budget tracker: V8 heap growth and the bridge's per-handle host + /// bookkeeping are charged against the resource budget through it. + pub fn snapi_bridge_unofficial_set_host_budget(env: SnapiEnv, data: *const c_void) -> i32; pub fn snapi_bridge_unofficial_get_promise_details( env: SnapiEnv, promise_id: u32, @@ -260,6 +256,12 @@ unsafe extern "C" { value_id: u32, payload_out: *mut u32, ) -> i32; + pub fn snapi_bridge_unofficial_message_create_metered( + env: SnapiEnv, + value_id: u32, + payload_out: *mut u32, + retained_bytes_out: *mut u64, + ) -> i32; pub fn snapi_bridge_unofficial_message_take( env: SnapiEnv, payload: u32, diff --git a/tests/build-test-native.sh b/tests/build-test-native.sh index e4d97eb3..c67163b8 100755 --- a/tests/build-test-native.sh +++ b/tests/build-test-native.sh @@ -173,6 +173,40 @@ if [[ -z "${V8_INCLUDE_DIR:-}" || -z "${V8_LIBRARY_PATH:-}" ]]; then exit 1 fi +# Keep the test embedder's V8 ABI in sync with the selected archive. Pointer +# compression alone is insufficient when the sandbox changes public structs. +V8_BUILD_CONFIG="$(dirname "$(dirname "$V8_LIBRARY_PATH")")/build-config.txt" +if [[ -f "$V8_BUILD_CONFIG" ]]; then + V8_DEFINES_NORMALIZED=",${V8_DEFINES//;/,}," + if grep -qx 'v8_enable_sandbox=true' "$V8_BUILD_CONFIG"; then + case "$V8_DEFINES_NORMALIZED" in + *,V8_ENABLE_SANDBOX,*|*,V8_ENABLE_SANDBOX=1,*) ;; + *,V8_ENABLE_SANDBOX=0,*) + echo "V8_ENABLE_SANDBOX=0 conflicts with the selected archive" >&2 + exit 1 + ;; + *) V8_DEFINES="$V8_DEFINES,V8_ENABLE_SANDBOX" ;; + esac + elif grep -qx 'v8_enable_sandbox=false' "$V8_BUILD_CONFIG"; then + case "$V8_DEFINES_NORMALIZED" in + *,V8_ENABLE_SANDBOX,*|*,V8_ENABLE_SANDBOX=1,*) + echo "V8_ENABLE_SANDBOX conflicts with the selected archive" >&2 + exit 1 + ;; + esac + else + echo "invalid V8 sandbox metadata: $V8_BUILD_CONFIG" >&2 + exit 1 + fi +fi +IFS=',; ' read -r -a V8_DEFINE_LIST <<< "$V8_DEFINES" +V8_DEFINE_ARGS=() +for define in "${V8_DEFINE_LIST[@]}"; do + if [[ -n "$define" ]]; then + V8_DEFINE_ARGS+=("-D$define") + fi +done + TEST_SRC="" for ext in c cc cpp; do candidate="$ROOT_DIR/tests/programs/${TEST_NAME}.${ext}" @@ -267,7 +301,7 @@ esac -w \ -DNAPI_EXTERN= \ -DNAPI_VERSION=8 \ - $(echo "$V8_DEFINES" | tr ';,' '\n' | sed '/^[[:space:]]*$/d; s/^[[:space:]]*/-D/; s/[[:space:]]*$//' | tr '\n' ' ') \ + "${V8_DEFINE_ARGS[@]}" \ -I"$NAPI_INCLUDE_DIR" \ -I"$NAPI_LIB_SRC" \ -I"$NAPI_V8_INCLUDE" \ diff --git a/tests/build-test-wasix.sh b/tests/build-test-wasix.sh index a05d35db..c278a750 100755 --- a/tests/build-test-wasix.sh +++ b/tests/build-test-wasix.sh @@ -68,7 +68,11 @@ fi WASIX_DRIVER="wasixcc" case "$TEST_SRC" in *.cc|*.cpp) - WASIX_DRIVER="wasixcc++" + # Recent wasixcc releases select the C++ driver from the input extension + # and do not install a separate wasixcc++ executable. + if command -v wasixcc++ >/dev/null 2>&1; then + WASIX_DRIVER="wasixcc++" + fi ;; esac diff --git a/tests/runners/test_21_general.cc b/tests/runners/test_21_general.cc index d6ad76e1..542e56a7 100644 --- a/tests/runners/test_21_general.cc +++ b/tests/runners/test_21_general.cc @@ -25,6 +25,12 @@ extern "C" void napi_host_guest_heap_release(void* ctx) { g_last_released_guest_heap_ctx = ctx; } +#if defined(NAPI_TEST_ENGINE_V8) +// Provider-internal accounting hook, intentionally absent from guest imports. +extern "C" size_t unofficial_napi_message_retained_bytes( + unofficial_napi_message message); +#endif + class Test21General : public FixtureTestBase {}; namespace { @@ -297,14 +303,19 @@ TEST_F(Test21General, MessageTakeConsumesOpaqueMessage) { unofficial_napi_message message = nullptr; ASSERT_EQ(unofficial_napi_message_create(s.env, source, &message), napi_ok); ASSERT_NE(message, nullptr); +#if defined(NAPI_TEST_ENGINE_V8) + EXPECT_GT(unofficial_napi_message_retained_bytes(message), 0u); +#endif + // A worker receives the payload in its own isolate, not the sender's. + EnvScope receiver(runtime_.get()); napi_value result = nullptr; - ASSERT_EQ(unofficial_napi_message_take(s.env, message, &result), napi_ok); + ASSERT_EQ(unofficial_napi_message_take(receiver.env, message, &result), napi_ok); ASSERT_NE(result, nullptr); napi_value answer = nullptr; - ASSERT_EQ(napi_get_named_property(s.env, result, "answer", &answer), napi_ok); + ASSERT_EQ(napi_get_named_property(receiver.env, result, "answer", &answer), napi_ok); uint32_t actual = 0; - ASSERT_EQ(napi_get_value_uint32(s.env, answer, &actual), napi_ok); + ASSERT_EQ(napi_get_value_uint32(receiver.env, answer, &actual), napi_ok); EXPECT_EQ(actual, 42u); unofficial_napi_message dropped = nullptr; @@ -313,6 +324,30 @@ TEST_F(Test21General, MessageTakeConsumesOpaqueMessage) { unofficial_napi_message_drop(dropped); } +#if defined(NAPI_TEST_ENGINE_V8) +TEST_F(Test21General, MessageSerializerRejectsOversizedPayload) { + EnvScope s(runtime_.get()); + std::string bytes(5 * 1024 * 1024, 'x'); + napi_value value = nullptr; + ASSERT_EQ(napi_create_string_utf8(s.env, bytes.data(), bytes.size(), &value), napi_ok); + unofficial_napi_message message = nullptr; + EXPECT_EQ(unofficial_napi_message_create(s.env, value, &message), + napi_pending_exception); + EXPECT_EQ(message, nullptr); +} + +TEST_F(Test21General, StructuredCloneRejectsOversizedPayload) { + EnvScope s(runtime_.get()); + std::string bytes(5 * 1024 * 1024, 'x'); + napi_value value = nullptr; + ASSERT_EQ(napi_create_string_utf8(s.env, bytes.data(), bytes.size(), &value), napi_ok); + napi_value cloned = nullptr; + EXPECT_EQ(unofficial_napi_structured_clone(s.env, value, nullptr, &cloned), + napi_pending_exception); + EXPECT_EQ(cloned, nullptr); +} +#endif + TEST_F(Test21General, ProviderFiltersIndexedPropertyNamesInBulk) { EnvScope s(runtime_.get()); diff --git a/tests/runners/test_29_bigint.cc b/tests/runners/test_29_bigint.cc index 023c5cf1..476a5ceb 100644 --- a/tests/runners/test_29_bigint.cc +++ b/tests/runners/test_29_bigint.cc @@ -15,3 +15,38 @@ TEST_F(Test29BigInt, PortedCoreFlow) { ASSERT_TRUE( RunUpstreamJsFile(s, std::string(NAPI_TESTS_ROOT_PATH) + "/js-native-api/test_bigint/test.js")); } + +TEST_F(Test29BigInt, MultiwordQueryAndShortBuffersStayWithinCapacity) { + EnvScope s(runtime_.get()); + const uint64_t expected[] = {0x1122334455667788ULL, 0x99aabbccddeeff00ULL, + 0x123456789abcdef0ULL}; + napi_value bigint = nullptr; + ASSERT_EQ(napi_create_bigint_words(s.env, 1, 3, expected, &bigint), napi_ok); + + int sign = 0; + size_t count = 0; + ASSERT_EQ(napi_get_value_bigint_words(s.env, bigint, &sign, &count, nullptr), + napi_ok); + EXPECT_EQ(sign, 1); + EXPECT_EQ(count, 3u); + + constexpr uint64_t sentinel = 0xdeadbeefcafebabeULL; + uint64_t guarded[] = {sentinel, 0, sentinel}; + count = 1; + sign = 0; + ASSERT_EQ(napi_get_value_bigint_words(s.env, bigint, &sign, &count, + &guarded[1]), + napi_ok); + EXPECT_EQ(sign, 1); + EXPECT_EQ(count, 3u); // Required size, not the one word written. + EXPECT_EQ(guarded[0], sentinel); + EXPECT_EQ(guarded[1], expected[0]); + EXPECT_EQ(guarded[2], sentinel); + + uint64_t actual[3] = {}; + count = 3; + ASSERT_EQ(napi_get_value_bigint_words(s.env, bigint, &sign, &count, actual), + napi_ok); + EXPECT_EQ(count, 3u); + for (size_t i = 0; i < 3; ++i) EXPECT_EQ(actual[i], expected[i]); +} diff --git a/tests/runners/test_31_dataview.cc b/tests/runners/test_31_dataview.cc index aec7276c..c700bcf7 100644 --- a/tests/runners/test_31_dataview.cc +++ b/tests/runners/test_31_dataview.cc @@ -2,6 +2,18 @@ #include "upstream_js_test.h" extern "C" napi_value Init(napi_env env, napi_value exports); +#if defined(NAPI_TEST_ENGINE_V8) +extern "C" void napi_v8_test_fail_next_external_buffer_after_transfer(); +extern "C" napi_status NAPI_CDECL napi_create_external_buffer( + napi_env env, size_t length, void* data, node_api_basic_finalize finalize_cb, + void* finalize_hint, napi_value* result); + +namespace { +void CountExternalBufferFinalizer(node_api_basic_env, void*, void* hint) { + ++*static_cast(hint); +} +} // namespace +#endif class Test31DataView : public FixtureTestBase {}; @@ -34,3 +46,19 @@ TEST_F(Test31DataView, CreateArrayBufferWithNullDataOutParam) { ASSERT_NE(data, nullptr); ASSERT_EQ(byte_length, 12u); } + +#if defined(NAPI_TEST_ENGINE_V8) +TEST_F(Test31DataView, ExternalBufferFailureStillTransfersFinalizerOwnership) { + EnvScope s(runtime_.get()); + uint8_t bytes[8] = {}; + int finalizer_calls = 0; + napi_value buffer = nullptr; + napi_v8_test_fail_next_external_buffer_after_transfer(); + EXPECT_EQ(napi_create_external_buffer(s.env, sizeof(bytes), bytes, + CountExternalBufferFinalizer, + &finalizer_calls, &buffer), + napi_generic_failure); + EXPECT_EQ(buffer, nullptr); + EXPECT_EQ(finalizer_calls, 1); +} +#endif diff --git a/tests/runners/test_65_unofficial_contextify.cc b/tests/runners/test_65_unofficial_contextify.cc index fe060631..2efc2392 100644 --- a/tests/runners/test_65_unofficial_contextify.cc +++ b/tests/runners/test_65_unofficial_contextify.cc @@ -20,6 +20,33 @@ napi_value Sym(napi_env env, const char* value) { return out; } +#if defined(NAPI_TEST_ENGINE_QUICKJS) +unofficial_napi_module CreateSourceModule(napi_env env, const char* url, const char* text) { + napi_value wrapper = nullptr; + napi_value undefined = nullptr; + if (napi_create_object(env, &wrapper) != napi_ok || + napi_get_undefined(env, &undefined) != napi_ok) { + return nullptr; + } + const unofficial_napi_js_source source = + unofficial_napi_js_source_from_text(Str(env, text)); + unofficial_napi_module_create_options options{}; + options.size = sizeof(options); + options.version = UNOFFICIAL_NAPI_MODULE_CREATE_OPTIONS_VERSION; + options.kind = unofficial_napi_module_source_text; + options.wrapper = wrapper; + options.url = Str(env, url); + options.context_or_undefined = undefined; + options.payload.source_text.source = &source; + options.payload.source_text.host_defined_option_id = undefined; + unofficial_napi_module_create_result created{}; + if (unofficial_napi_module_wrap_create(env, &options, &created) != napi_ok) { + return nullptr; + } + return created.module; +} +#endif + #if defined(NAPI_TEST_ENGINE_V8) constexpr char kPreparedStack[] = "Error: sentinel\n" @@ -106,6 +133,86 @@ TEST_F(Test65UnofficialContextify, MakeRunRoundTrip) { } +#if defined(NAPI_TEST_ENGINE_V8) +TEST_F(Test65UnofficialContextify, MakeContextBoundsNativeOwnPropertySnapshot) { + EnvScope s(runtime_.get()); + napi_value sandbox = nullptr; + ASSERT_EQ(napi_run_script( + s.env, + Str(s.env, + "(() => { const object = {}; for (let i = 0; i < 16385; ++i) " + "object['k' + i] = i; return object; })()"), + &sandbox), + napi_ok); + napi_value undefined = nullptr; + ASSERT_EQ(napi_get_undefined(s.env, &undefined), napi_ok); + napi_value result = nullptr; + EXPECT_EQ(unofficial_napi_contextify_make_context( + s.env, sandbox, Str(s.env, "many-properties"), undefined, + true, true, false, undefined, &result), + napi_generic_failure); + EXPECT_EQ(result, nullptr); +} + +TEST_F(Test65UnofficialContextify, NativeEnvironmentsRetainWebAssemblyInAllContexts) { + EnvScope s(runtime_.get()); + constexpr char kCompileEmptyModule[] = + "new WebAssembly.Module(new Uint8Array([0,97,115,109,1,0,0,0]))"; + napi_value source = Str(s.env, "typeof WebAssembly"); + ASSERT_NE(source, nullptr); + + napi_value result = nullptr; + ASSERT_EQ(napi_run_script(s.env, source, &result), napi_ok); + char type[16] = {}; + ASSERT_EQ(napi_get_value_string_utf8(s.env, result, type, sizeof(type), nullptr), napi_ok); + EXPECT_STREQ(type, "object"); + ASSERT_EQ(napi_run_script(s.env, Str(s.env, kCompileEmptyModule), &result), napi_ok); + napi_valuetype value_type = napi_undefined; + ASSERT_EQ(napi_typeof(s.env, result, &value_type), napi_ok); + EXPECT_EQ(value_type, napi_object); + + napi_value sandbox = nullptr; + napi_value undefined = nullptr; + ASSERT_EQ(napi_create_object(s.env, &sandbox), napi_ok); + ASSERT_EQ(napi_get_undefined(s.env, &undefined), napi_ok); + napi_value context = nullptr; + ASSERT_EQ(unofficial_napi_contextify_make_context( + s.env, sandbox, undefined, undefined, true, true, true, + undefined, &context), napi_ok); + const unofficial_napi_js_source context_source = + unofficial_napi_js_source_from_text(source); + ASSERT_EQ(unofficial_napi_contextify_run_script( + s.env, context, &context_source, undefined, 0, 0, -1, + true, false, false, undefined, &result), napi_ok); + ASSERT_EQ(napi_get_value_string_utf8(s.env, result, type, sizeof(type), nullptr), napi_ok); + EXPECT_STREQ(type, "object"); + const unofficial_napi_js_source compile_source = + unofficial_napi_js_source_from_text(Str(s.env, kCompileEmptyModule)); + ASSERT_EQ(unofficial_napi_contextify_run_script( + s.env, context, &compile_source, undefined, 0, 0, -1, + true, false, false, undefined, &result), napi_ok); + ASSERT_EQ(napi_typeof(s.env, result, &value_type), napi_ok); + EXPECT_EQ(value_type, napi_object); + + napi_value disabled_sandbox = nullptr; + ASSERT_EQ(napi_create_object(s.env, &disabled_sandbox), napi_ok); + ASSERT_EQ(unofficial_napi_contextify_make_context( + s.env, disabled_sandbox, undefined, undefined, true, false, + true, undefined, &context), napi_ok); + const unofficial_napi_js_source denied_source = + unofficial_napi_js_source_from_text(Str( + s.env, + "try { new WebAssembly.Module(new Uint8Array([0,97,115,109,1,0,0,0])); " + "'allowed' } catch (error) { error.name }")); + ASSERT_EQ(unofficial_napi_contextify_run_script( + s.env, context, &denied_source, undefined, 0, 0, -1, + true, false, false, undefined, &result), napi_ok); + ASSERT_EQ(napi_get_value_string_utf8(s.env, result, type, sizeof(type), nullptr), napi_ok); + EXPECT_STREQ(type, "CompileError"); +} +#endif + + #if defined(NAPI_TEST_ENGINE_V8) TEST_F(Test65UnofficialContextify, MakeContextPreservesThrownProxyException) { EnvScope s(runtime_.get()); @@ -422,6 +529,9 @@ TEST_F(Test65UnofficialContextify, ModuleStateIsOneAtomicSnapshot) { ASSERT_EQ(napi_typeof(s.env, error, &error_type), napi_ok); EXPECT_EQ(error_type, napi_undefined); + // Reject before allocating or copying the caller's oversized link list. + EXPECT_EQ(unofficial_napi_module_wrap_link(s.env, module, 4097, nullptr), + napi_invalid_arg); ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, module, 0, nullptr), napi_ok); ASSERT_EQ(unofficial_napi_module_wrap_instantiate(s.env, module), napi_ok); status = -1; @@ -435,6 +545,269 @@ TEST_F(Test65UnofficialContextify, ModuleStateIsOneAtomicSnapshot) { EXPECT_EQ(unofficial_napi_module_wrap_destroy(s.env, module), napi_ok); } +TEST_F(Test65UnofficialContextify, DestroyedDependencyInvalidatesIncomingLinks) { + EnvScope s(runtime_.get()); + napi_value undefined = nullptr; + ASSERT_EQ(napi_get_undefined(s.env, &undefined), napi_ok); + + auto create_module = [&](const char* url, const char* text) { + napi_value wrapper = nullptr; + EXPECT_EQ(napi_create_object(s.env, &wrapper), napi_ok); + const unofficial_napi_js_source source = + unofficial_napi_js_source_from_text(Str(s.env, text)); + unofficial_napi_module_create_options options{}; + options.size = sizeof(options); + options.version = UNOFFICIAL_NAPI_MODULE_CREATE_OPTIONS_VERSION; + options.kind = unofficial_napi_module_source_text; + options.wrapper = wrapper; + options.url = Str(s.env, url); + options.context_or_undefined = undefined; + options.payload.source_text.source = &source; + options.payload.source_text.host_defined_option_id = undefined; + unofficial_napi_module_create_result created{}; + EXPECT_EQ(unofficial_napi_module_wrap_create(s.env, &options, &created), napi_ok); + return created.module; + }; + + unofficial_napi_module dependency = + create_module("dep.mjs", "export const dep = 2;"); + unofficial_napi_module parent = + create_module("parent.mjs", "import './dep.mjs'; export const value = 1;"); + ASSERT_NE(dependency, nullptr); + ASSERT_NE(parent, nullptr); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, dependency, 0, nullptr), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, parent, 1, &dependency), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_destroy(s.env, dependency), napi_ok); + + EXPECT_EQ(unofficial_napi_module_wrap_instantiate(s.env, parent), + napi_pending_exception); + napi_value exception = nullptr; + ASSERT_EQ(napi_get_and_clear_last_exception(s.env, &exception), napi_ok); + EXPECT_NE(exception, nullptr); + EXPECT_EQ(unofficial_napi_module_wrap_destroy(s.env, parent), napi_ok); +} + +#if defined(NAPI_TEST_ENGINE_QUICKJS) +TEST_F(Test65UnofficialContextify, TransitiveDestroyedDependencyBlocksImplicitLinking) { + EnvScope s(runtime_.get()); + unofficial_napi_module dependency = + CreateSourceModule(s.env, "dep.mjs", "export const dep = 2;"); + unofficial_napi_module middle = + CreateSourceModule(s.env, "middle.mjs", "import './dep.mjs'; export const middle = 3;"); + unofficial_napi_module root = + CreateSourceModule(s.env, "root.mjs", "import './middle.mjs'; export const root = 4;"); + ASSERT_NE(dependency, nullptr); + ASSERT_NE(middle, nullptr); + ASSERT_NE(root, nullptr); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, dependency, 0, nullptr), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, middle, 1, &dependency), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, root, 1, &middle), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_destroy(s.env, dependency), napi_ok); + + auto assert_pending = [&](napi_status status) { + EXPECT_EQ(status, napi_pending_exception); + napi_value exception = nullptr; + EXPECT_EQ(napi_get_and_clear_last_exception(s.env, &exception), napi_ok); + EXPECT_NE(exception, nullptr); + }; + assert_pending(unofficial_napi_module_wrap_instantiate(s.env, root)); + napi_value result = nullptr; + assert_pending(unofficial_napi_module_wrap_evaluate(s.env, root, -1, false, &result)); + assert_pending(unofficial_napi_module_wrap_evaluate_sync( + s.env, root, Str(s.env, "root.mjs"), Str(s.env, "parent.mjs"), &result)); + assert_pending(unofficial_napi_module_wrap_create_required_module_facade(s.env, root, &result)); + + EXPECT_EQ(unofficial_napi_module_wrap_destroy(s.env, root), napi_ok); + EXPECT_EQ(unofficial_napi_module_wrap_destroy(s.env, middle), napi_ok); +} + +TEST_F(Test65UnofficialContextify, LinkedGraphSurvivesDestroyedDependencyWrapper) { + EnvScope s(runtime_.get()); + unofficial_napi_module dependency = + CreateSourceModule(s.env, "dep.mjs", "export const dep = 2;"); + unofficial_napi_module root = + CreateSourceModule(s.env, "root.mjs", "import { dep } from './dep.mjs'; export const root = dep + 2;"); + unofficial_napi_module outer = + CreateSourceModule(s.env, "outer.mjs", "import { root } from './root.mjs'; export const outer = root + 1;"); + ASSERT_NE(dependency, nullptr); + ASSERT_NE(root, nullptr); + ASSERT_NE(outer, nullptr); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, dependency, 0, nullptr), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, root, 1, &dependency), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_instantiate(s.env, root), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_destroy(s.env, dependency), napi_ok); + EXPECT_EQ(unofficial_napi_module_wrap_instantiate(s.env, root), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, outer, 1, &root), napi_ok); + EXPECT_EQ(unofficial_napi_module_wrap_instantiate(s.env, outer), napi_ok); + napi_value result = nullptr; + EXPECT_EQ(unofficial_napi_module_wrap_evaluate_sync( + s.env, outer, Str(s.env, "outer.mjs"), Str(s.env, "parent.mjs"), &result), + napi_ok); + EXPECT_NE(result, nullptr); + napi_value namespace_value = nullptr; + napi_value exported_value = nullptr; + int32_t exported = 0; + ASSERT_EQ(unofficial_napi_module_wrap_get_namespace(s.env, outer, &namespace_value), napi_ok); + ASSERT_EQ(napi_get_named_property(s.env, namespace_value, "outer", &exported_value), napi_ok); + ASSERT_EQ(napi_get_value_int32(s.env, exported_value, &exported), napi_ok); + EXPECT_EQ(exported, 5); + EXPECT_EQ(unofficial_napi_module_wrap_destroy(s.env, outer), napi_ok); + EXPECT_EQ(unofficial_napi_module_wrap_destroy(s.env, root), napi_ok); +} + +TEST_F(Test65UnofficialContextify, CyclicGraphLinksWithoutRecursion) { + EnvScope s(runtime_.get()); + unofficial_napi_module a = + CreateSourceModule(s.env, "a.mjs", "import './b.mjs'; export const a = 1;"); + unofficial_napi_module b = + CreateSourceModule(s.env, "b.mjs", "import './a.mjs'; export const b = 2;"); + ASSERT_NE(a, nullptr); + ASSERT_NE(b, nullptr); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, a, 1, &b), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, b, 1, &a), napi_ok); + EXPECT_EQ(unofficial_napi_module_wrap_instantiate(s.env, a), napi_ok); + EXPECT_EQ(unofficial_napi_module_wrap_destroy(s.env, a), napi_ok); + EXPECT_EQ(unofficial_napi_module_wrap_destroy(s.env, b), napi_ok); +} + +TEST_F(Test65UnofficialContextify, InvalidatedParentCanRelinkReplacement) { + EnvScope s(runtime_.get()); + unofficial_napi_module old_dependency = + CreateSourceModule(s.env, "old.mjs", "export const dep = 1;"); + unofficial_napi_module root = + CreateSourceModule(s.env, "root.mjs", "import { dep } from './dep.mjs'; export const root = dep + 2;"); + ASSERT_NE(old_dependency, nullptr); + ASSERT_NE(root, nullptr); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, old_dependency, 0, nullptr), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, root, 1, &old_dependency), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_destroy(s.env, old_dependency), napi_ok); + + unofficial_napi_module replacement = + CreateSourceModule(s.env, "new.mjs", "export const dep = 2;"); + ASSERT_NE(replacement, nullptr); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, replacement, 0, nullptr), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_link(s.env, root, 1, &replacement), napi_ok); + EXPECT_EQ(unofficial_napi_module_wrap_instantiate(s.env, root), napi_ok); + napi_value result = nullptr; + EXPECT_EQ(unofficial_napi_module_wrap_evaluate_sync( + s.env, root, Str(s.env, "root.mjs"), Str(s.env, "parent.mjs"), &result), + napi_ok); + napi_value namespace_value = nullptr; + napi_value exported_value = nullptr; + int32_t exported = 0; + ASSERT_EQ(unofficial_napi_module_wrap_get_namespace(s.env, root, &namespace_value), napi_ok); + ASSERT_EQ(napi_get_named_property(s.env, namespace_value, "root", &exported_value), napi_ok); + ASSERT_EQ(napi_get_value_int32(s.env, exported_value, &exported), napi_ok); + EXPECT_EQ(exported, 4); + EXPECT_EQ(unofficial_napi_module_wrap_destroy(s.env, root), napi_ok); + EXPECT_EQ(unofficial_napi_module_wrap_destroy(s.env, replacement), napi_ok); +} +#endif + +#if defined(NAPI_TEST_ENGINE_V8) + + +TEST_F(Test65UnofficialContextify, CompileFunctionBoundsNativeFilenameScratch) { + EnvScope s(runtime_.get()); + const unofficial_napi_js_source source = + unofficial_napi_js_source_from_text(Str(s.env, "return 1;")); + std::string large_filename(1024 * 1024 + 1, 'a'); + napi_value filename = nullptr; + ASSERT_EQ(napi_create_string_utf8(s.env, large_filename.data(), + large_filename.size(), &filename), napi_ok); + napi_value undefined = nullptr; + ASSERT_EQ(napi_get_undefined(s.env, &undefined), napi_ok); + napi_value result = nullptr; + EXPECT_EQ(unofficial_napi_contextify_compile_function( + s.env, &source, filename, 0, 0, undefined, undefined, + undefined, undefined, &result), napi_invalid_arg); + EXPECT_EQ(result, nullptr); +} + +TEST_F(Test65UnofficialContextify, ModuleRequestMetadataHasPerEnvLimit) { + EnvScope s(runtime_.get()); + napi_value wrapper = nullptr; + napi_value undefined = nullptr; + ASSERT_EQ(napi_create_object(s.env, &wrapper), napi_ok); + ASSERT_EQ(napi_get_undefined(s.env, &undefined), napi_ok); + + // Reuse one V8 source string: V8 can intern the specifier while each module + // record otherwise retains two uncharged native copies of it. + const std::string source_text = "import '" + std::string(256 * 1024, 'a') + "';"; + napi_value source_value = nullptr; + ASSERT_EQ(napi_create_string_utf8(s.env, source_text.data(), source_text.size(), + &source_value), napi_ok); + const unofficial_napi_js_source source = + unofficial_napi_js_source_from_text(source_value); + unofficial_napi_module_create_options options{}; + options.size = sizeof(options); + options.version = UNOFFICIAL_NAPI_MODULE_CREATE_OPTIONS_VERSION; + options.kind = unofficial_napi_module_source_text; + options.wrapper = wrapper; + options.url = Str(s.env, "same.mjs"); + options.context_or_undefined = undefined; + options.payload.source_text.source = &source; + options.payload.source_text.host_defined_option_id = undefined; + + std::vector modules; + bool denied = false; + for (size_t i = 0; i < 32; ++i) { + unofficial_napi_module_create_result created{}; + const napi_status status = unofficial_napi_module_wrap_create(s.env, &options, &created); + if (status != napi_ok) { + EXPECT_EQ(status, napi_generic_failure); + denied = true; + break; + } + modules.push_back(created.module); + } + ASSERT_TRUE(denied); + ASSERT_FALSE(modules.empty()); + EXPECT_LT(modules.size(), 16u); + + ASSERT_EQ(unofficial_napi_module_wrap_destroy(s.env, modules.back()), napi_ok); + modules.pop_back(); + unofficial_napi_module_create_result retry{}; + ASSERT_EQ(unofficial_napi_module_wrap_create(s.env, &options, &retry), napi_ok); + ASSERT_EQ(unofficial_napi_module_wrap_destroy(s.env, retry.module), napi_ok); + for (unofficial_napi_module module : modules) + ASSERT_EQ(unofficial_napi_module_wrap_destroy(s.env, module), napi_ok); +} + +TEST_F(Test65UnofficialContextify, PendingDynamicImportsCannotGrowWithoutBound) { + EnvScope s(runtime_.get()); + napi_value callback = nullptr; + ASSERT_EQ(napi_run_script( + s.env, + Str(s.env, + "globalThis.neverImport = new Promise(() => {});" + "globalThis.dynamicImportCalls = 0;" + "() => { ++dynamicImportCalls; return neverImport; }"), + &callback), + napi_ok); + unofficial_napi_module_hooks hooks{}; + hooks.size = sizeof(hooks); + hooks.version = UNOFFICIAL_NAPI_MODULE_HOOKS_VERSION; + hooks.import_module_dynamically = callback; + ASSERT_EQ(unofficial_napi_module_wrap_set_hooks(s.env, &hooks), napi_ok); + + napi_value ignored = nullptr; + ASSERT_EQ(napi_run_script( + s.env, + Str(s.env, "for (let i = 0; i < 5000; ++i) import('never').catch(() => {});" + "dynamicImportCalls"), + &ignored), napi_pending_exception); + napi_value exception = nullptr; + ASSERT_EQ(napi_get_and_clear_last_exception(s.env, &exception), napi_ok); + ASSERT_NE(exception, nullptr); + ASSERT_EQ(napi_run_script(s.env, Str(s.env, "dynamicImportCalls"), &ignored), + napi_ok); + uint32_t calls = 0; + ASSERT_EQ(napi_get_value_uint32(s.env, ignored, &calls), napi_ok); + EXPECT_EQ(calls, 4096u); +} +#endif + TEST_F(Test65UnofficialContextify, SyncModuleErrorDoesNotLeaveUnhandledRejection) { EnvScope s(runtime_.get()); diff --git a/tests/v8_lazy_lane.rs b/tests/v8_lazy_lane.rs new file mode 100644 index 00000000..80dff41a --- /dev/null +++ b/tests/v8_lazy_lane.rs @@ -0,0 +1,124 @@ +#![cfg(feature = "cli")] + +use std::{ + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + thread, + time::Duration, +}; + +use wasmer_napi::{ManagedV8Lane, NapiCtx, cli::run_wasix_main_capture_stdio_with_hooks}; + +mod common; +use common::build_wasix_test; + +unsafe extern "C" { + fn snapi_v8_fallback_worker_posts() -> u64; + fn snapi_v8_platform_created() -> bool; + fn snapi_v8_standalone_pool_created() -> bool; + fn snapi_v8_unattributed_worker_posts() -> u64; +} + +struct TaskGuard(Arc); + +impl Drop for TaskGuard { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +#[test] +fn embedder_lane_starts_on_first_env_and_routes_gc_tasks() { + let configure_only = build_wasix_test("test_configure_without_env"); + let wasm = build_wasix_test("test_gc_once"); + let spawns = Arc::new(AtomicUsize::new(0)); + let scopes = Arc::new(AtomicUsize::new(0)); + let scope_exits = Arc::new(AtomicUsize::new(0)); + let (finished_tx, finished_rx) = mpsc::channel(); + let lane_slot: Arc>>> = Arc::new(Mutex::new(None)); + let hooks = NapiCtx::builder() + .total_memory_bytes(512 * 1024 * 1024) + .build_managed_imports({ + let spawns = Arc::clone(&spawns); + let lane_slot = Arc::clone(&lane_slot); + let scopes = Arc::clone(&scopes); + let scope_exits = Arc::clone(&scope_exits); + Arc::new(move || { + let mut slot = lane_slot.lock().unwrap(); + if let Some(lane) = &*slot { + return Ok(Arc::clone(lane)); + } + let task_scope = { + let scopes = Arc::clone(&scopes); + let scope_exits = Arc::clone(&scope_exits); + Arc::new(move || { + scopes.fetch_add(1, Ordering::SeqCst); + Box::new(TaskGuard(Arc::clone(&scope_exits))) as Box + }) + }; + let lane = ManagedV8Lane::new(256, task_scope, Arc::new(|| {}))?; + let worker_lane = Arc::clone(&lane); + let finished_tx = finished_tx.clone(); + thread::Builder::new() + .name("napi-test-lane".into()) + .spawn(move || { + worker_lane.run(); + let _ = finished_tx.send(()); + })?; + spawns.fetch_add(1, Ordering::SeqCst); + *slot = Some(Arc::clone(&lane)); + Ok(lane) + }) + }); + let budget = hooks.budget(); + let fallback_before = unsafe { snapi_v8_fallback_worker_posts() }; + let unattributed_before = unsafe { snapi_v8_unattributed_worker_posts() }; + assert_eq!(spawns.load(Ordering::SeqCst), 0); + assert_eq!(budget.snapshot().v8_background_lane, 0); + assert!(!unsafe { snapi_v8_platform_created() }); + let (exit_code, stdout, stderr) = + run_wasix_main_capture_stdio_with_hooks(&hooks, &configure_only, &[], &[]).unwrap(); + assert_eq!(exit_code, 0, "{stderr}"); + assert!( + stdout.contains("CONFIGURED_WITHOUT_ENV"), + "{stdout}\n{stderr}" + ); + assert_eq!(spawns.load(Ordering::SeqCst), 0); + assert!(!unsafe { snapi_v8_platform_created() }); + + let (exit_code, stdout, stderr) = + run_wasix_main_capture_stdio_with_hooks(&hooks, &wasm, &[], &[]).unwrap(); + assert_eq!(exit_code, 0, "{stderr}"); + assert!(stdout.contains("GC_DONE"), "{stdout}\n{stderr}"); + assert_eq!(spawns.load(Ordering::SeqCst), 1); + assert!(lane_slot.lock().unwrap().is_some()); + assert!(unsafe { snapi_v8_platform_created() }); + assert!(!unsafe { snapi_v8_standalone_pool_created() }); + assert_eq!( + budget.snapshot().v8_background_lane, + 0, + "the embedder, not N-API, owns the lane reservation" + ); + + lane_slot.lock().unwrap().as_ref().unwrap().stop(); + finished_rx + .recv_timeout(Duration::from_secs(5)) + .expect("lane worker exits"); + drop(hooks); + assert_eq!( + unsafe { snapi_v8_fallback_worker_posts() }, + fallback_before, + "a managed V8 task escaped to the process-wide fallback pool" + ); + assert_eq!( + unsafe { snapi_v8_unattributed_worker_posts() }, + unattributed_before, + "managed V8 posted work without its instance lane" + ); + let started = scopes.load(Ordering::SeqCst); + assert!(started > 0, "GC did not exercise the metered V8 lane"); + assert_eq!(scope_exits.load(Ordering::SeqCst), started); +} diff --git a/v8/src/edge_v8_platform.cc b/v8/src/edge_v8_platform.cc index 5aedc530..5bb6832b 100644 --- a/v8/src/edge_v8_platform.cc +++ b/v8/src/edge_v8_platform.cc @@ -1,12 +1,15 @@ #include "edge_v8_platform.h" +#include #include #include #include #include #include +#include #include #include +#include #include #include @@ -15,6 +18,139 @@ namespace { +// A lane belongs to one N-API context. Only its dedicated embedder-owned +// thread executes V8 background work. The bounded queue prevents a guest +// from accumulating unbounded host-side Task objects while the lane is busy. +class BackgroundLane { + public: + BackgroundLane(size_t max_queued_tasks, + void* scope_context, void* (*enter_scope)(void*), + bool (*leave_scope)(void*, void*), + void (*on_overload)(void*)) + : max_queued_tasks_(max_queued_tasks), scope_context_(scope_context), enter_scope_(enter_scope), + leave_scope_(leave_scope), on_overload_(on_overload) {} + + bool Post(std::unique_ptr task, double delay_seconds) { + if (!task) return true; + using Clock = std::chrono::steady_clock; + const double bounded_delay = std::isfinite(delay_seconds) + ? std::clamp(delay_seconds, 0.0, 86400.0) : 0.0; + const auto delay = std::chrono::duration_cast( + std::chrono::duration(bounded_delay)); + { + std::lock_guard lock(mutex_); + if (stopped_) return false; + if (queue_.size() < max_queued_tasks_) { + Item item{Clock::now() + delay, std::move(task)}; + auto it = queue_.begin(); + while (it != queue_.end() && it->due <= item.due) ++it; + queue_.insert(it, std::move(item)); + cv_.notify_one(); + return true; + } + // Stop admission before signaling the embedder. It may need to acquire + // other V8 locks to terminate the isolates, so never call it under ours. + overloaded_ = true; + stopped_ = true; + cv_.notify_all(); + } + if (on_overload_ != nullptr) on_overload_(scope_context_); + return false; + } + + void Run() { + v8::ThreadIsolatedAllocator::SetDefaultPermissionsForSignalHandler(); + std::unique_lock lock(mutex_); + if (running_) return; + running_ = true; + cv_.notify_all(); + while (!stopped_) { + if (queue_.empty()) { + cv_.wait(lock, [&] { return stopped_ || !queue_.empty(); }); + continue; + } + auto due = queue_.front().due; + if (std::chrono::steady_clock::now() < due) { + cv_.wait_until(lock, due); + continue; + } + auto task = std::move(queue_.front().task); + queue_.pop_front(); + lock.unlock(); + void* scope = enter_scope_ != nullptr ? enter_scope_(scope_context_) : nullptr; + if (enter_scope_ != nullptr && scope == nullptr) { + SignalOverload(); + lock.lock(); + break; + } + // The lane can be created before V8 allocates its Linux JIT pkey. New + // pkeys default to access-disabled on an already-running thread. + v8::ThreadIsolatedAllocator::SetDefaultPermissionsForSignalHandler(); + task->Run(); + if (leave_scope_ != nullptr && !leave_scope_(scope_context_, scope)) { + SignalOverload(); + lock.lock(); + break; + } + lock.lock(); + } + queue_.clear(); + running_ = false; + } + + void Stop() { + std::lock_guard lock(mutex_); + stopped_ = true; + cv_.notify_all(); + } + + bool IsRunning() { + std::lock_guard lock(mutex_); + return running_; + } + + bool IsOverloaded() { + std::lock_guard lock(mutex_); + return overloaded_; + } + + void SignalOverload() { + bool notify = false; + { + std::lock_guard lock(mutex_); + if (!overloaded_) { + overloaded_ = true; + notify = true; + } + stopped_ = true; + cv_.notify_all(); + } + if (notify && on_overload_ != nullptr) on_overload_(scope_context_); + } + + private: + using Clock = std::chrono::steady_clock; + struct Item { + Clock::time_point due; + std::unique_ptr task; + }; + std::mutex mutex_; + std::condition_variable cv_; + std::deque queue_; + bool running_ = false; + bool stopped_ = false; + bool overloaded_ = false; + size_t max_queued_tasks_ = 0; + void* scope_context_ = nullptr; + void* (*enter_scope_)(void*) = nullptr; + bool (*leave_scope_)(void*, void*) = nullptr; + void (*on_overload_)(void*) = nullptr; +}; + +thread_local BackgroundLane* current_background_lane = nullptr; +std::atomic fallback_worker_posts{0}; +std::atomic unattributed_worker_posts{0}; + struct WorkerWarmupState { std::mutex mutex; std::condition_variable cv; @@ -78,6 +214,78 @@ void CleanupForegroundTaskRecord(napi_env /*env*/, void* data); } // namespace +extern "C" void* snapi_v8_lane_new(size_t max_queued_tasks, + void* scope_context, + void* (*enter_scope)(void*), + bool (*leave_scope)(void*, void*), + void (*on_overload)(void*)) { + if (max_queued_tasks == 0) return nullptr; + return new (std::nothrow) + BackgroundLane(max_queued_tasks, scope_context, enter_scope, leave_scope, on_overload); +} + +extern "C" void snapi_v8_lane_run(void* handle) { + if (handle == nullptr) return; + auto* lane = static_cast(handle); + auto* previous = current_background_lane; + current_background_lane = lane; + lane->Run(); + current_background_lane = previous; +} + +extern "C" void snapi_v8_lane_stop(void* handle) { + if (handle != nullptr) static_cast(handle)->Stop(); +} + +extern "C" void snapi_v8_lane_delete(void* handle) { + delete static_cast(handle); +} + +extern "C" void* snapi_v8_lane_swap_current(void* handle) { + auto* previous = current_background_lane; + current_background_lane = static_cast(handle); + return previous; +} + +extern "C" void* snapi_v8_lane_current() { + return current_background_lane; +} + +extern "C" bool snapi_v8_lane_is_running(void* handle) { + return handle != nullptr && static_cast(handle)->IsRunning(); +} + +extern "C" bool snapi_v8_lane_overloaded(void* handle) { + return handle != nullptr && static_cast(handle)->IsOverloaded(); +} + +extern "C" uint64_t snapi_v8_fallback_worker_posts() { + return fallback_worker_posts.load(std::memory_order_acquire); +} + +extern "C" uint64_t snapi_v8_unattributed_worker_posts() { + return unattributed_worker_posts.load(std::memory_order_acquire); +} + +// Native-only regression hook. It is not reachable through guest imports. +// Exercising the actual V8 Task queue catches accidental process-wide pool +// routing that a Rust-only state-machine test cannot detect. +extern "C" bool snapi_v8_lane_post_test_task(void* handle, + void (*callback)(void*), void* data) { + if (handle == nullptr || callback == nullptr) return false; + class CallbackTask final : public v8::Task { + public: + CallbackTask(void (*callback)(void*), void* data) + : callback_(callback), data_(data) {} + void Run() override { callback_(data_); } + private: + void (*callback_)(void*); + void* data_; + }; + return static_cast(handle)->Post( + std::make_unique(callback, data), 0.0); +} + struct EdgeV8Platform::FinishedCallback { void (*callback)(void*) = nullptr; void* data = nullptr; @@ -86,14 +294,17 @@ struct EdgeV8Platform::FinishedCallback { struct EdgeV8Platform::IsolateState { IsolateState(EdgeV8Platform* platform_in, v8::Isolate* isolate_in, - std::shared_ptr runner_in) + std::shared_ptr runner_in, + bool standalone_workers_in) : platform(platform_in), isolate(isolate_in), - runner(std::move(runner_in)) {} + runner(std::move(runner_in)), + standalone_workers(standalone_workers_in) {} EdgeV8Platform* platform = nullptr; v8::Isolate* isolate = nullptr; std::shared_ptr runner; + bool standalone_workers = false; std::mutex mutex; size_t pending_foreground_tasks = 0; bool shutdown_started = false; @@ -220,23 +431,27 @@ class EdgeV8Platform::ForegroundTaskRunner final : public v8::TaskRunner { if (enqueue != nullptr && target != nullptr) { ForegroundTaskRecord* record = new (std::nothrow) ForegroundTaskRecord(); if (record != nullptr) { - record->isolate_state.reset(); + // The embedder may run and clean up the task before enqueue returns. + // Publish all record state and charge pending work first. + if (isolate_state != nullptr && isolate_state->platform != nullptr) { + isolate_state->platform->AddPendingForegroundTask(isolate_state); + record->isolate_state = isolate_state; + } record->task = std::move(task); if (enqueue(target, RunForegroundTaskRecord, record, CleanupForegroundTaskRecord, delay_ms) == napi_ok) { - if (isolate_state != nullptr && - isolate_state->platform != nullptr) { - isolate_state->platform->AddPendingForegroundTask(isolate_state); - record->isolate_state = isolate_state; - } return; } task = std::move(record->task); - record->isolate_state.reset(); + std::shared_ptr pending_state = + std::move(record->isolate_state); delete record; + if (pending_state != nullptr && pending_state->platform != nullptr) { + pending_state->platform->CompletePendingForegroundTask(pending_state); + } } } @@ -301,8 +516,17 @@ namespace { // when the platform is built. std::atomic g_worker_thread_count{0}; std::atomic g_platform_created{false}; +std::atomic g_standalone_pool_created{false}; } // namespace +extern "C" bool snapi_v8_platform_created() { + return g_platform_created.load(std::memory_order_acquire); +} + +extern "C" bool snapi_v8_standalone_pool_created() { + return g_standalone_pool_created.load(std::memory_order_acquire); +} + bool EdgeV8Platform::SetWorkerThreadCount(int count) { const int requested = count < 0 ? 0 : count; if (g_platform_created.load(std::memory_order_acquire)) { @@ -315,13 +539,20 @@ bool EdgeV8Platform::SetWorkerThreadCount(int count) { return true; } -std::unique_ptr EdgeV8Platform::Create() { +std::unique_ptr EdgeV8Platform::Create(bool standalone_workers) { g_platform_created.store(true, std::memory_order_release); - std::unique_ptr fallback = v8::platform::NewDefaultPlatform( - g_worker_thread_count.load(std::memory_order_acquire)); + // This delegate supplies foreground runners, clocks, and allocators. V8 + // sees EdgeV8Platform (not this delegate), which implements every worker/job + // entry point. The delegate's documented --single-threaded requirement is + // for passing it directly to V8; here the outer platform supplies workers. + // Its own pool must stay absent in a managed Edge process: those tasks + // belong to the instance lane and its CPU/memory accounting. + std::unique_ptr fallback = + v8::platform::NewSingleThreadedDefaultPlatform(); if (!fallback) return nullptr; - WarmUpFallbackWorkerThreads(fallback.get()); - return std::unique_ptr(new EdgeV8Platform(std::move(fallback))); + auto platform = std::unique_ptr(new EdgeV8Platform(std::move(fallback))); + if (standalone_workers && !platform->EnableStandaloneWorkers()) return nullptr; + return platform; } EdgeV8Platform::EdgeV8Platform(std::unique_ptr fallback) @@ -329,6 +560,24 @@ EdgeV8Platform::EdgeV8Platform(std::unique_ptr fallback) EdgeV8Platform::~EdgeV8Platform() = default; +bool EdgeV8Platform::EnableStandaloneWorkers() { + std::lock_guard lock(standalone_workers_mutex_); + if (standalone_workers_) return true; + auto workers = v8::platform::NewDefaultPlatform( + g_worker_thread_count.load(std::memory_order_acquire)); + if (!workers) return false; + WarmUpFallbackWorkerThreads(workers.get()); + standalone_workers_ = std::move(workers); + standalone_workers_ptr_.store(standalone_workers_.get(), + std::memory_order_release); + g_standalone_pool_created.store(true, std::memory_order_release); + return true; +} + +v8::Platform* EdgeV8Platform::StandaloneWorkers() { + return standalone_workers_ptr_.load(std::memory_order_acquire); +} + std::shared_ptr EdgeV8Platform::GetState(v8::Isolate* isolate) { if (isolate == nullptr) return nullptr; std::lock_guard lock(mutex_); @@ -346,7 +595,14 @@ std::shared_ptr EdgeV8Platform::EnsureState(v8::Is // exists for every isolate it tears down. (void)fallback_->GetForegroundTaskRunner(isolate); } - auto state = std::make_shared(this, isolate, nullptr); + const bool standalone_workers = current_background_lane == nullptr && + StandaloneWorkers() != nullptr; + if (standalone_workers) { + // libplatform's shutdown helper expects an isolate runner to exist. + (void)StandaloneWorkers()->GetForegroundTaskRunner(isolate); + } + auto state = std::make_shared(this, isolate, nullptr, + standalone_workers); state->runner = std::make_shared(state, isolate, fallback_.get()); isolates_.emplace(isolate, state); return state; @@ -458,11 +714,16 @@ void EdgeV8Platform::NotifyIsolateShutdown(v8::Isolate* isolate) { if (runner) { runner->NotifyIsolateShutdown(); } - // The fallback platform owns all background V8 work for these isolates, - // even when foreground tasks stayed on the Edge-specific runner. + // The foreground delegate owns the fallback runner used before guest + // callbacks are bound. A standalone isolate also needs its worker pool's + // shutdown notification; managed background tasks belong to its lane. if (fallback_ != nullptr) { v8::platform::NotifyIsolateShutdown(fallback_.get(), isolate); } + if (state != nullptr && state->standalone_workers) { + auto* workers = StandaloneWorkers(); + v8::platform::NotifyIsolateShutdown(workers, isolate); + } BeginShutdown(state); MaybeFinishIsolate(state, false); } @@ -525,7 +786,9 @@ void EdgeV8Platform::PumpPendingForegroundTasks(v8::Isolate* isolate) { } int EdgeV8Platform::NumberOfWorkerThreads() { - return fallback_ != nullptr ? fallback_->NumberOfWorkerThreads() : 0; + if (current_background_lane != nullptr) return 1; + auto* workers = StandaloneWorkers(); + return workers != nullptr ? workers->NumberOfWorkerThreads() : 0; } std::shared_ptr EdgeV8Platform::GetForegroundTaskRunner( @@ -598,8 +861,17 @@ std::unique_ptr EdgeV8Platform::CreateJobImpl( void EdgeV8Platform::PostTaskOnWorkerThreadImpl(v8::TaskPriority priority, std::unique_ptr task, const v8::SourceLocation& location) { - if (fallback_ != nullptr) { - fallback_->PostTaskOnWorkerThread(priority, std::move(task), location); + if (current_background_lane != nullptr) { + current_background_lane->Post(std::move(task), 0.0); + return; + } + if (auto* workers = StandaloneWorkers()) { + fallback_worker_posts.fetch_add(1, std::memory_order_relaxed); + workers->PostTaskOnWorkerThread(priority, std::move(task), location); + } else { + // A managed worker task without a bound lane has no safe tenant to bill. + // Discard it rather than starting an unmetered global worker. + unattributed_worker_posts.fetch_add(1, std::memory_order_relaxed); } } @@ -608,7 +880,14 @@ void EdgeV8Platform::PostDelayedTaskOnWorkerThreadImpl( std::unique_ptr task, double delay_in_seconds, const v8::SourceLocation& location) { - if (fallback_ != nullptr) { - fallback_->PostDelayedTaskOnWorkerThread(priority, std::move(task), delay_in_seconds, location); + if (current_background_lane != nullptr) { + current_background_lane->Post(std::move(task), delay_in_seconds); + return; + } + if (auto* workers = StandaloneWorkers()) { + fallback_worker_posts.fetch_add(1, std::memory_order_relaxed); + workers->PostDelayedTaskOnWorkerThread(priority, std::move(task), delay_in_seconds, location); + } else { + unattributed_worker_posts.fetch_add(1, std::memory_order_relaxed); } } diff --git a/v8/src/edge_v8_platform.h b/v8/src/edge_v8_platform.h index 8544948d..142bf652 100644 --- a/v8/src/edge_v8_platform.h +++ b/v8/src/edge_v8_platform.h @@ -1,6 +1,8 @@ #ifndef NAPI_V8_EDGE_V8_PLATFORM_H_ #define NAPI_V8_EDGE_V8_PLATFORM_H_ +#include +#include #include #include #include @@ -10,12 +12,30 @@ #include "unofficial_napi.h" +// An embedder-owned background execution lane. The returned handle is owned by +// the embedder until Stop, Run and all associated N-API environments have +// quiesced. A null current lane uses the process-wide worker pool only after a +// standalone environment explicitly enables it. +extern "C" void* snapi_v8_lane_new(size_t max_queued_tasks, + void* scope_context, + void* (*enter_scope)(void*), + bool (*leave_scope)(void*, void*), + void (*on_overload)(void*)); +extern "C" void snapi_v8_lane_run(void* handle); +extern "C" void snapi_v8_lane_stop(void* handle); +extern "C" void snapi_v8_lane_delete(void* handle); +extern "C" void* snapi_v8_lane_swap_current(void* handle); +extern "C" void* snapi_v8_lane_current(); +extern "C" bool snapi_v8_lane_is_running(void* handle); +extern "C" bool snapi_v8_lane_overloaded(void* handle); + class EdgeV8Platform final : public v8::Platform { public: struct FinishedCallback; struct IsolateState; - static std::unique_ptr Create(); + static std::unique_ptr Create(bool standalone_workers); + bool EnableStandaloneWorkers(); // Sets how many background worker threads the platform gets. Zero restores // V8's own default, which sizes the pool from the host's processor count -- @@ -23,8 +43,9 @@ class EdgeV8Platform final : public v8::Platform { // many, where those threads compete with every tenant's foreground JS and // their work is not attributed to anyone. // - // The pool is process-wide and built when the first isolate is created, so - // this has no effect afterwards; it returns false in that case. + // The standalone pool is process-wide and built only when a standalone + // environment is configured. The setting freezes with V8 platform creation, + // even when the first environment is managed. static bool SetWorkerThreadCount(int count); ~EdgeV8Platform() override; @@ -79,6 +100,7 @@ class EdgeV8Platform final : public v8::Platform { class ForegroundTaskRunner; explicit EdgeV8Platform(std::unique_ptr fallback); + v8::Platform* StandaloneWorkers(); std::shared_ptr EnsureState(v8::Isolate* isolate); std::shared_ptr EnsureRunner(v8::Isolate* isolate); @@ -88,6 +110,11 @@ class EdgeV8Platform final : public v8::Platform { bool begin_shutdown); std::unique_ptr fallback_; + // Managed isolates dispatch every worker task to their instance lane. The + // process-wide V8 pool exists only when a standalone environment is used. + std::mutex standalone_workers_mutex_; + std::unique_ptr standalone_workers_; + std::atomic standalone_workers_ptr_{nullptr}; std::mutex mutex_; std::unordered_map> isolates_; }; diff --git a/v8/src/internal/napi_module_wrap_record.h b/v8/src/internal/napi_module_wrap_record.h index 90032dfe..c1486190 100644 --- a/v8/src/internal/napi_module_wrap_record.h +++ b/v8/src/internal/napi_module_wrap_record.h @@ -34,6 +34,7 @@ struct ModuleWrapRecord { std::vector module_requests; std::unordered_map resolve_cache; std::vector linked_requests; + size_t request_metadata_bytes = 0; }; } // namespace v8impl::detail diff --git a/v8/src/internal/napi_v8_env.h b/v8/src/internal/napi_v8_env.h index 0c7a5f11..b961852c 100644 --- a/v8/src/internal/napi_v8_env.h +++ b/v8/src/internal/napi_v8_env.h @@ -110,6 +110,9 @@ struct napi_env__ { unofficial_napi_enqueue_foreground_task_callback enqueue_foreground_task_callback = nullptr; void* enqueue_foreground_task_target = nullptr; bool embedder_hooks_attached = false; + // Guest-heap-backed environments must not expose V8 allocations that bypass + // the workload's linear-memory accountant. Native embedders keep V8 Wasm. + bool restrict_unmetered_webassembly = false; #if defined(NAPI_ENABLE_LIFETIME_TRACKER) && defined(NAPI_ENABLE_LIFETIME_PERIODIC_STATS) napi::periodic_gate__ lifetime_stats_gate_{2000}; diff --git a/v8/src/internal/restricted_context.h b/v8/src/internal/restricted_context.h new file mode 100644 index 00000000..c2dfb46c --- /dev/null +++ b/v8/src/internal/restricted_context.h @@ -0,0 +1,40 @@ +#ifndef NAPI_V8_RESTRICTED_CONTEXT_H_ +#define NAPI_V8_RESTRICTED_CONTEXT_H_ + +#include + +// Provider-created contexts use one embedder slot for their Wasm compilation +// policy. Unmarked contexts fail closed, including new realms that did not go +// through contextify. Slot 0 is reserved by V8's debugger. +constexpr int kNapiWasmCodeGenerationPolicyIndex = 1; + +inline void SetWasmCodeGenerationAllowed(v8::Local context, + bool allowed) { + context->SetEmbedderData(kNapiWasmCodeGenerationPolicyIndex, + v8::Boolean::New(context->GetIsolate(), allowed)); +} + +inline bool AllowWasmCodeGeneration(v8::Local context, + v8::Local /*source*/) { + if (context->GetNumberOfEmbedderDataFields() <= + kNapiWasmCodeGenerationPolicyIndex) { + return false; + } + return context->GetEmbedderData(kNapiWasmCodeGenerationPolicyIndex)->IsTrue(); +} + +// V8's WebAssembly.Memory allocations do not use the guest linear-memory +// accountant. Do not expose the WebAssembly constructor in guest-heap-backed +// contexts until those allocations can be charged to the owning workload. +inline bool RemoveUnmeteredWebAssembly(v8::Local context) { + v8::Isolate* isolate = context->GetIsolate(); + v8::Local key = + v8::String::NewFromUtf8Literal(isolate, "WebAssembly"); + v8::Local global = context->Global(); + if (!global->Delete(context, key).FromMaybe(false)) return false; + v8::Local remaining; + return global->Get(context, key).ToLocal(&remaining) && + remaining->IsUndefined(); +} + +#endif // NAPI_V8_RESTRICTED_CONTEXT_H_ diff --git a/v8/src/internal/unofficial_napi_bridge.h b/v8/src/internal/unofficial_napi_bridge.h index b1baf85e..e823862d 100644 --- a/v8/src/internal/unofficial_napi_bridge.h +++ b/v8/src/internal/unofficial_napi_bridge.h @@ -11,7 +11,23 @@ bool NapiV8LookupForegroundTaskTarget(v8::Isolate* isolate, bool NapiV8IsContextifyContext(napi_env env, v8::Local context); void NapiV8ApplyPromiseHooksToContext(napi_env env, v8::Local context); void NapiV8ApplyPromiseHooksToContextifyContexts(napi_env env); -void NapiV8TrackProviderPromise(v8::Isolate* isolate, +class NapiV8ProviderPromiseReservation { + public: + explicit NapiV8ProviderPromiseReservation(v8::Isolate* isolate); + ~NapiV8ProviderPromiseReservation(); + + NapiV8ProviderPromiseReservation(const NapiV8ProviderPromiseReservation&) = delete; + NapiV8ProviderPromiseReservation& operator=(const NapiV8ProviderPromiseReservation&) = delete; + + bool acquired() const { return acquired_; } + + private: + v8::Isolate* isolate_; + bool acquired_; + bool reserved_; +}; + +bool NapiV8TrackProviderPromise(v8::Isolate* isolate, v8::Local promise); bool NapiV8HasPendingProviderWork(napi_env env); diff --git a/v8/src/js_native_api_v8.cc b/v8/src/js_native_api_v8.cc index 395f301f..56355b68 100644 --- a/v8/src/js_native_api_v8.cc +++ b/v8/src/js_native_api_v8.cc @@ -61,6 +61,8 @@ v8::MaybeLocal NapiHostImportModuleDynamically( v8::Local /*import_attributes*/) { v8::Isolate* isolate = context->GetIsolate(); v8::EscapableHandleScope handle_scope(isolate); + NapiV8ProviderPromiseReservation reservation(isolate); + if (!reservation.acquired()) return v8::MaybeLocal(); v8::TryCatch try_catch(isolate); v8::Local resolver; @@ -86,7 +88,10 @@ v8::MaybeLocal NapiHostImportModuleDynamically( (!global->Get(context, helper_name).ToLocal(&helper_value) || !helper_value->IsFunction())) { v8::Local message = v8::String::NewFromUtf8Literal(isolate, "Not supported"); resolver->Reject(context, v8::Exception::Error(message)).FromMaybe(false); - NapiV8TrackProviderPromise(isolate, promise); + if (!NapiV8TrackProviderPromise(isolate, promise)) { + try_catch.ReThrow(); + return v8::MaybeLocal(); + } return handle_scope.Escape(promise); } @@ -101,18 +106,27 @@ v8::MaybeLocal NapiHostImportModuleDynamically( v8::Local message = v8::String::NewFromUtf8Literal(isolate, "Not supported"); resolver->Reject(context, v8::Exception::Error(message)).FromMaybe(false); } - NapiV8TrackProviderPromise(isolate, promise); + if (!NapiV8TrackProviderPromise(isolate, promise)) { + try_catch.ReThrow(); + return v8::MaybeLocal(); + } return handle_scope.Escape(promise); } if (result->IsPromise()) { v8::Local result_promise = result.As(); - NapiV8TrackProviderPromise(isolate, result_promise); + if (!NapiV8TrackProviderPromise(isolate, result_promise)) { + try_catch.ReThrow(); + return v8::MaybeLocal(); + } return handle_scope.Escape(result_promise); } resolver->Resolve(context, result).FromMaybe(false); - NapiV8TrackProviderPromise(isolate, promise); + if (!NapiV8TrackProviderPromise(isolate, promise)) { + try_catch.ReThrow(); + return v8::MaybeLocal(); + } return handle_scope.Escape(promise); } @@ -2630,40 +2644,39 @@ napi_status NAPI_CDECL napi_wrap(napi_env env, return napi_v8_set_last_error(env, napi_invalid_arg, "Invalid argument"); } - napi_ref ref = nullptr; + // The object's private slot must own its tracker independently of the + // optional user reference. Deleting a user reference must never free the + // tracker that napi_unwrap/napi_remove_wrap later read from that slot. + napi_ref wrapped_ref = finalize_cb != nullptr + ? static_cast(napi_ref_with_finalizer__::New( + env, object, 0, napi_ref_ownership__::kRuntime, + finalize_cb, native_object, finalize_hint)) + : static_cast(napi_ref_with_data__::New( + env, object, 0, napi_ref_ownership__::kRuntime, + native_object)); + if (wrapped_ref == nullptr) return napi_generic_failure; + napi_ref user_ref = nullptr; if (result != nullptr) { - ref = napi_ref_with_finalizer__::New(env, - object, - 0, - napi_ref_ownership__::kUserland, - finalize_cb, - native_object, - finalize_hint); - } else if (finalize_cb != nullptr) { - ref = napi_ref_with_finalizer__::New(env, - object, - 0, - napi_ref_ownership__::kRuntime, - finalize_cb, - native_object, - finalize_hint); - } else { - ref = napi_ref_with_data__::New( - env, object, 0, napi_ref_ownership__::kRuntime, native_object); + user_ref = napi_ref__::New(env, object, 0, + napi_ref_ownership__::kUserland); + if (user_ref == nullptr) { + wrapped_ref->Destroy(); + return napi_generic_failure; + } } - if (ref == nullptr) return napi_generic_failure; if (!object ->SetPrivate(env->context(), wrapKey, - v8::External::New(env->isolate, ref)) + v8::External::New(env->isolate, wrapped_ref)) .FromMaybe(false)) { - ref->Destroy(); + if (user_ref != nullptr) user_ref->Destroy(); + wrapped_ref->Destroy(); return napi_generic_failure; } if (result != nullptr) { - *result = ref; + *result = user_ref; } return napi_ok; } diff --git a/v8/src/unofficial_napi.cc b/v8/src/unofficial_napi.cc index 66c7ed76..5bd653a4 100644 --- a/v8/src/unofficial_napi.cc +++ b/v8/src/unofficial_napi.cc @@ -28,6 +28,7 @@ #include "internal/napi_v8_env.h" #include "internal/napi_serdes_context.h" #include "internal/unofficial_napi_bridge.h" +#include "internal/restricted_context.h" #include "unofficial_napi_error_utils.h" #include "edge_v8_platform.h" @@ -349,7 +350,8 @@ void ResetPrepareStackTraceState(PrepareStackTraceState* state) { } v8::Local LookupPrepareStackTraceCallback(napi_env env, - v8::Local context) { + v8::Local context, + bool is_contextify_context) { if (env == nullptr || env->isolate == nullptr || context.IsEmpty()) { return v8::Local(); } @@ -363,7 +365,7 @@ v8::Local LookupPrepareStackTraceCallback(napi_env env, v8::Local principal_context = env->context(); const bool use_principal_callback = !principal_context.IsEmpty() && - (context == principal_context || NapiV8IsContextifyContext(env, context)); + (context == principal_context || is_contextify_context); if (use_principal_callback) { return state.principal_callback.Get(context->GetIsolate()); @@ -396,9 +398,13 @@ v8::MaybeLocal NapiPrepareStackTraceCallback(v8::Local c } v8::Local callback; + // Contextify membership takes g_context_mu. Resolve it before g_runtime_mu + // so promise-hook updates cannot take the two locks in the opposite order. + const bool is_contextify_context = NapiV8IsContextifyContext(env, context); { std::lock_guard lock(g_runtime_mu); - callback = LookupPrepareStackTraceCallback(env, context); + callback = LookupPrepareStackTraceCallback(env, context, + is_contextify_context); } if (callback.IsEmpty()) { @@ -697,12 +703,17 @@ napi_status ConfigureRuntime(const char* engine_flags, } std::lock_guard lock(g_runtime_mu); if (g_runtime.platform != nullptr) { - return g_runtime.engine_flags.size() == engine_flags_length && - (engine_flags_length == 0 || - std::memcmp(g_runtime.engine_flags.data(), engine_flags, - engine_flags_length) == 0) - ? napi_ok - : napi_invalid_arg; + const bool flags_match = + g_runtime.engine_flags.size() == engine_flags_length && + (engine_flags_length == 0 || + std::memcmp(g_runtime.engine_flags.data(), engine_flags, + engine_flags_length) == 0); + if (!flags_match) return napi_invalid_arg; + if (snapi_v8_lane_current() == nullptr && + !g_runtime.platform->EnableStandaloneWorkers()) { + return napi_generic_failure; + } + return napi_ok; } ApplyDefaultV8Flags(); @@ -712,7 +723,8 @@ napi_status ConfigureRuntime(const char* engine_flags, } v8::V8::InitializeICUDefaultLocation(""); v8::V8::InitializeExternalStartupData(""); - g_runtime.platform = EdgeV8Platform::Create(); + g_runtime.platform = EdgeV8Platform::Create( + snapi_v8_lane_current() == nullptr); if (g_runtime.platform == nullptr) return napi_generic_failure; v8::V8::InitializePlatform(g_runtime.platform.get()); v8::V8::Initialize(); @@ -725,6 +737,10 @@ napi_status AcquireRuntime(EdgeV8Platform** platform_out) { if (platform_out == nullptr) return napi_invalid_arg; std::lock_guard lock(g_runtime_mu); if (g_runtime.platform == nullptr) return napi_invalid_arg; + if (snapi_v8_lane_current() == nullptr && + !g_runtime.platform->EnableStandaloneWorkers()) { + return napi_generic_failure; + } g_runtime.refcount++; *platform_out = g_runtime.platform.get(); return *platform_out != nullptr ? napi_ok : napi_generic_failure; @@ -1426,6 +1442,8 @@ namespace { using v8impl::detail::DeserializerContext; using v8impl::detail::SerializerContext; +void ThrowCloneTransferError(v8::Isolate* isolate, const char* message); + void SetProtoMethod(v8::Isolate* isolate, v8::Local tmpl, const char* name, @@ -1447,8 +1465,24 @@ bool SetConstructorFunction(v8::Local context, class StructuredCloneSerializerDelegate final : public v8::ValueSerializer::Delegate { public: - explicit StructuredCloneSerializerDelegate(v8::Isolate* isolate) - : isolate_(isolate) {} + explicit StructuredCloneSerializerDelegate(v8::Isolate* isolate, + size_t max_buffer_bytes = 0) + : isolate_(isolate), max_buffer_bytes_(max_buffer_bytes) {} + + void* ReallocateBufferMemory(void* old_buffer, size_t size, + size_t* actual_size) override { + if (max_buffer_bytes_ != 0 && size > max_buffer_bytes_) return nullptr; + void* buffer = std::realloc(old_buffer, size); + if (buffer != nullptr) { + *actual_size = size; + buffer_capacity_ = size; + } + return buffer; + } + + void FreeBufferMemory(void* buffer) override { std::free(buffer); } + + size_t buffer_capacity() const { return buffer_capacity_; } void ThrowDataCloneError(v8::Local message) override { isolate_->ThrowException(v8::Exception::Error(message)); @@ -1464,6 +1498,11 @@ class StructuredCloneSerializerDelegate final : public v8::ValueSerializer::Dele return v8::Just(i); } } + // Bound side-table metadata as well as the serialized byte buffer. + if (max_buffer_bytes_ != 0 && shared_array_buffers_.size() >= 65536) { + ThrowCloneTransferError(isolate_, "Too many shared buffers in message"); + return v8::Nothing(); + } shared_array_buffers_.push_back(std::move(backing_store)); return v8::Just(static_cast(shared_array_buffers_.size() - 1)); } @@ -1471,6 +1510,10 @@ class StructuredCloneSerializerDelegate final : public v8::ValueSerializer::Dele v8::Maybe GetWasmModuleTransferId( v8::Isolate* /*isolate*/, v8::Local module) override { + if (max_buffer_bytes_ != 0 && wasm_modules_.size() >= 64) { + ThrowCloneTransferError(isolate_, "Too many Wasm modules in message"); + return v8::Nothing(); + } wasm_modules_.push_back(module->GetCompiledModule()); return v8::Just(static_cast(wasm_modules_.size() - 1)); } @@ -1489,6 +1532,8 @@ class StructuredCloneSerializerDelegate final : public v8::ValueSerializer::Dele private: v8::Isolate* isolate_ = nullptr; + size_t max_buffer_bytes_ = 0; + size_t buffer_capacity_ = 0; std::vector> shared_array_buffers_; std::vector wasm_modules_; }; @@ -1524,7 +1569,9 @@ class StructuredCloneDeserializerDelegate final : public v8::ValueDeserializer:: }; struct SerializedClonePayload { - std::vector bytes; + std::unique_ptr bytes{nullptr, &std::free}; + size_t bytes_size = 0; + size_t bytes_capacity = 0; std::vector> array_buffers; std::vector> shared_array_buffers; std::vector wasm_modules; @@ -1570,6 +1617,10 @@ napi_status CollectTransferArrayBuffers( ThrowCloneTransferError(isolate, "Transfer list contains duplicate ArrayBuffer"); return napi_pending_exception; } + if (out->size() >= 65536) { + ThrowCloneTransferError(isolate, "Too many ArrayBuffers in transfer list"); + return napi_pending_exception; + } serializer->TransferArrayBuffer(static_cast(out->size()), array_buffer); out->push_back(array_buffer); } @@ -1595,7 +1646,8 @@ napi_status DetachTransferredArrayBuffers( napi_status DeserializeTransferredClone( napi_env env, - const std::vector& bytes, + const uint8_t* bytes, + size_t bytes_size, const std::vector>& array_buffers, const std::vector>& shared_array_buffers, const std::vector& wasm_modules, @@ -1610,8 +1662,8 @@ napi_status DeserializeTransferredClone( isolate, shared_array_buffers, wasm_modules); v8::ValueDeserializer deserializer( isolate, - bytes.data(), - bytes.size(), + bytes, + bytes_size, &deserializer_delegate); for (uint32_t i = 0; i < array_buffers.size(); ++i) { @@ -1649,7 +1701,8 @@ napi_status StructuredCloneImpl( v8::Context::Scope context_scope(context); v8::Local input = napi_v8_unwrap_value(value); - StructuredCloneSerializerDelegate serializer_delegate(isolate); + // The guest reserves native transient memory before entering this call. + StructuredCloneSerializerDelegate serializer_delegate(isolate, 4 * 1024 * 1024); v8::ValueSerializer serializer(isolate, &serializer_delegate); std::vector> array_buffers; @@ -1674,11 +1727,11 @@ napi_status StructuredCloneImpl( if (released.first == nullptr) return napi_generic_failure; std::unique_ptr buffer(released.first, &std::free); - std::vector bytes(buffer.get(), buffer.get() + released.second); v8::Local output; napi_status deserialize_status = DeserializeTransferredClone( env, - bytes, + buffer.get(), + released.second, transferred_array_buffers, serializer_delegate.shared_array_buffers(), serializer_delegate.wasm_modules(), @@ -1926,9 +1979,11 @@ napi_status NAPI_CDECL unofficial_napi_create_env( return status != napi_ok ? status : napi_generic_failure; } - // Existing workers can predate the JIT protection key allocated by the - // first isolate. Restore read access before initializing a second isolate's - // builtin dispatch table; restoring permissions at bridge entry is too late. + // Linux gives newly created threads the PKRU state of their creator. A + // worker thread that predates V8's process-wide JIT protection key may + // still have access disabled for that key. V8's dispatch table is then + // unreadable even though its page mapping is readable. Restore V8's + // documented default (read-only) permissions on this execution thread. v8::ThreadIsolatedAllocator::SetDefaultPermissionsForSignalHandler(); auto allocator = std::make_shared(); @@ -1988,6 +2043,19 @@ napi_status NAPI_CDECL unofficial_napi_create_env( } v8::Local context = v8::Context::New(isolate); + if (context.IsEmpty() || + (guest_heap != nullptr && !RemoveUnmeteredWebAssembly(context))) { + delete scope; + DisposeIsolateAndWait(platform, isolate); + { + std::lock_guard lock(g_runtime_mu); + g_tracking_allocators.erase(allocator.get()); + } + ReleaseRuntime(); + return napi_generic_failure; + } + SetWasmCodeGenerationAllowed(context, guest_heap == nullptr); + isolate->SetAllowWasmCodeGenerationCallback(AllowWasmCodeGeneration); scope->context.emplace(isolate, context); scope->context_scope.emplace(context); status = unofficial_napi_create_env_from_context(context, module_api_version, &scope->env); @@ -2001,6 +2069,7 @@ napi_status NAPI_CDECL unofficial_napi_create_env( ReleaseRuntime(); return (status == napi_ok) ? napi_generic_failure : status; } + scope->env->restrict_unmetered_webassembly = guest_heap != nullptr; *env_out = scope->env; *owner_out = reinterpret_cast(scope); @@ -2065,6 +2134,8 @@ napi_status NAPI_CDECL unofficial_napi_set_prepare_stack_trace_callback( if (current_context.IsEmpty()) { current_context = env->context(); } + const bool is_contextify_context = + !current_context.IsEmpty() && NapiV8IsContextifyContext(env, current_context); bool has_prepare_stack_trace_callback = false; { @@ -2075,7 +2146,7 @@ napi_status NAPI_CDECL unofficial_napi_set_prepare_stack_trace_callback( current_context.IsEmpty() || (!principal_context.IsEmpty() && (current_context == principal_context || - NapiV8IsContextifyContext(env, current_context))); + is_contextify_context)); if (use_principal_callback) { state.principal_callback.Reset(); @@ -2269,7 +2340,10 @@ napi_status NAPI_CDECL unofficial_napi_message_create( v8::Context::Scope context_scope(context); v8::Local input = napi_v8_unwrap_value(value); - StructuredCloneSerializerDelegate serializer_delegate(isolate); + // Worker messages are retained outside the V8 heap until another worker + // consumes them. Bound the native serializer buffer before it allocates. + constexpr size_t kMaxMessageBytes = 4 * 1024 * 1024; + StructuredCloneSerializerDelegate serializer_delegate(isolate, kMaxMessageBytes); v8::ValueSerializer serializer(isolate, &serializer_delegate); serializer.WriteHeader(); @@ -2285,14 +2359,25 @@ napi_status NAPI_CDECL unofficial_napi_message_create( std::free(released.first); return napi_generic_failure; } - payload->bytes.assign(released.first, released.first + released.second); - std::free(released.first); + payload->bytes.reset(released.first); + payload->bytes_size = released.second; + payload->bytes_capacity = serializer_delegate.buffer_capacity(); payload->shared_array_buffers = serializer_delegate.shared_array_buffers(); payload->wasm_modules = serializer_delegate.TakeWasmModules(); *message_out = reinterpret_cast(payload); return napi_ok; } +size_t unofficial_napi_message_retained_bytes(unofficial_napi_message message) { + auto* payload = reinterpret_cast(message); + if (payload == nullptr) return 0; + return sizeof(*payload) + payload->bytes_capacity + + payload->array_buffers.capacity() * sizeof(payload->array_buffers[0]) + + payload->shared_array_buffers.capacity() * + sizeof(payload->shared_array_buffers[0]) + + payload->wasm_modules.capacity() * sizeof(payload->wasm_modules[0]); +} + napi_status NAPI_CDECL unofficial_napi_message_take( napi_env env, unofficial_napi_message message, @@ -2313,7 +2398,8 @@ napi_status NAPI_CDECL unofficial_napi_message_take( v8::Local output; napi_status deserialize_status = DeserializeTransferredClone( env, - payload->bytes, + payload->bytes.get(), + payload->bytes_size, payload->array_buffers, payload->shared_array_buffers, payload->wasm_modules, diff --git a/v8/src/unofficial_napi_contextify.cc b/v8/src/unofficial_napi_contextify.cc index ed23b406..f0fe9962 100644 --- a/v8/src/unofficial_napi_contextify.cc +++ b/v8/src/unofficial_napi_contextify.cc @@ -32,9 +32,12 @@ #include "internal/napi_v8_env.h" #include "internal/napi_module_wrap_record.h" #include "internal/unofficial_napi_bridge.h" +#include "internal/restricted_context.h" #include "node_api.h" #include "unofficial_napi_error_utils.h" +bool NapiV8HasPendingProviderWork(napi_env env); + namespace { constexpr int kHostDefinedOptionsId = 8; @@ -86,9 +89,19 @@ struct ModuleWrapBindingState { napi_ref initialize_import_meta_ref = nullptr; std::vector modules; std::vector pending_dynamic_imports; + size_t pending_dynamic_imports_in_flight = 0; ModuleWrapRecord* temporary_required_module_facade_original = nullptr; + size_t request_metadata_bytes = 0; }; +// These native copies sit outside V8's heap accounting. Keep their maximum +// below the fixed per-isolate overhead reserved by the host budget. +constexpr size_t kMaxLiveModuleWraps = 2048; +constexpr size_t kMaxModuleRequests = 4096; +constexpr size_t kMaxModuleRequestMetadata = 4 * 1024 * 1024; +constexpr size_t kMaxPendingProviderPromises = 4096; +constexpr int kMaxDynamicImportAttributeEntries = 2048; + napi_value GetSymbolsBindingProperty(napi_env env, const char* property_name); napi_value GetSourceTextModuleDefaultHdoSymbol(napi_env env); @@ -602,12 +615,20 @@ napi_env GetModuleWrapEnvForIsolate(v8::Isolate* isolate) { ModuleWrapBindingState* GetModuleWrapState(napi_env env) { if (env == nullptr) return nullptr; + std::lock_guard lock(g_module_wrap_mu); return &g_module_wrap_states[env]; } +ModuleWrapBindingState* FindModuleWrapState(napi_env env) { + if (env == nullptr) return nullptr; + std::lock_guard lock(g_module_wrap_mu); + auto it = g_module_wrap_states.find(env); + return it == g_module_wrap_states.end() ? nullptr : &it->second; +} + void RemoveModuleRecord(napi_env env, ModuleWrapRecord* record) { if (env == nullptr || record == nullptr) return; - auto* state = GetModuleWrapState(env); + auto* state = FindModuleWrapState(env); if (state == nullptr) return; auto& modules = state->modules; modules.erase(std::remove(modules.begin(), modules.end(), record), modules.end()); @@ -616,6 +637,22 @@ void RemoveModuleRecord(napi_env env, ModuleWrapRecord* record) { void DestroyModuleRecord(ModuleWrapRecord* record) { if (record == nullptr || record->env == nullptr) return; napi_env env = record->env; + if (auto* state = FindModuleWrapState(env)) { + if (record->request_metadata_bytes != 0) { + state->request_metadata_bytes -= record->request_metadata_bytes; + record->request_metadata_bytes = 0; + } + // Other modules keep raw dependency pointers for V8's resolve callback. + // Invalidate every incoming link before freeing this record. + for (ModuleWrapRecord* dependent : state->modules) { + if (dependent == nullptr || dependent == record) continue; + for (ModuleWrapRecord*& linked : dependent->linked_requests) { + if (linked == record) linked = nullptr; + } + } + if (state->temporary_required_module_facade_original == record) + state->temporary_required_module_facade_original = nullptr; + } RemoveModuleRecord(env, record); ResetRef(env, &record->wrapper_ref); ResetRef(env, &record->synthetic_eval_steps_ref); @@ -748,21 +785,29 @@ bool SnapshotOwnProperties(v8::Isolate* isolate, return false; } - out->reserve(names->Length()); - for (uint32_t i = 0; i < names->Length(); ++i) { - v8::Local key_value; - if (!names->Get(context, i).ToLocal(&key_value) || !key_value->IsName()) { - continue; - } - v8::Local key = key_value.As(); - v8::Local value; - if (!object->Get(context, key).ToLocal(&value)) { - return false; + // The Rust import reserves for this vector and its persistent handles + // before V8 can invoke a sandbox getter or Proxy trap. + if (names->Length() > 16 * 1024) return false; + try { + out->reserve(names->Length()); + for (uint32_t i = 0; i < names->Length(); ++i) { + v8::Local key_value; + if (!names->Get(context, i).ToLocal(&key_value) || !key_value->IsName()) { + continue; + } + v8::Local key = key_value.As(); + v8::Local value; + if (!object->Get(context, key).ToLocal(&value)) { + return false; + } + SavedOwnProperty saved; + saved.key.Reset(isolate, key); + saved.value.Reset(isolate, value); + out->push_back(std::move(saved)); } - SavedOwnProperty saved; - saved.key.Reset(isolate, key); - saved.value.Reset(isolate, value); - out->push_back(std::move(saved)); + } catch (const std::bad_alloc&) { + out->clear(); + return false; } return true; } @@ -783,12 +828,16 @@ bool RestoreOwnProperties(v8::Isolate* isolate, void CleanupContextRecords(void* arg) { napi_env env = static_cast(arg); - - std::lock_guard lock(g_context_mu); - g_context_cleanup_hooks.erase(env); - auto it = g_context_records.find(env); - if (it == g_context_records.end()) return; - for (auto& rec : it->second) { + std::vector records; + { + std::lock_guard lock(g_context_mu); + g_context_cleanup_hooks.erase(env); + auto it = g_context_records.find(env); + if (it == g_context_records.end()) return; + records = std::move(it->second); + g_context_records.erase(it); + } + for (auto& rec : records) { if (rec.key_ref != nullptr && env->context_token_unassign_callback != nullptr) { env->context_token_unassign_callback( env, rec.key_ref, env->context_token_callback_data); @@ -800,7 +849,6 @@ void CleanupContextRecords(void* arg) { rec.context.Reset(); rec.own_microtask_queue.reset(); } - g_context_records.erase(it); } void EnsureContextCleanupHook(napi_env env) { @@ -813,13 +861,18 @@ void EnsureContextCleanupHook(napi_env env) { void CleanupModuleWrapState(void* arg) { napi_env env = static_cast(arg); - - std::lock_guard lock(g_module_wrap_mu); - g_module_wrap_cleanup_hooks.erase(env); - auto it = g_module_wrap_states.find(env); - if (it == g_module_wrap_states.end()) return; - - for (ModuleWrapRecord* record : it->second.modules) { + ModuleWrapBindingState state; + { + std::lock_guard lock(g_module_wrap_mu); + g_module_wrap_cleanup_hooks.erase(env); + auto it = g_module_wrap_states.find(env); + if (it == g_module_wrap_states.end()) return; + state = std::move(it->second); + g_module_wrap_states.erase(it); + } + // Releasing refs and modules enters V8. Keep the registry lock out of that + // work so teardown of one isolate cannot stall another isolate's lookup. + for (ModuleWrapRecord* record : state.modules) { if (record == nullptr) continue; ResetRef(env, &record->wrapper_ref); ResetRef(env, &record->synthetic_eval_steps_ref); @@ -830,18 +883,21 @@ void CleanupModuleWrapState(void* arg) { env->release(record); } - ResetRef(env, &it->second.import_module_dynamically_ref); - ResetRef(env, &it->second.initialize_import_meta_ref); - for (auto& promise : it->second.pending_dynamic_imports) promise.value.Reset(); - it->second.pending_dynamic_imports.clear(); - g_module_wrap_states.erase(it); + ResetRef(env, &state.import_module_dynamically_ref); + ResetRef(env, &state.initialize_import_meta_ref); + for (auto& promise : state.pending_dynamic_imports) promise.value.Reset(); } void EnsureModuleWrapCleanupHook(napi_env env) { - auto [it, inserted] = g_module_wrap_cleanup_hooks.emplace(env); + bool inserted; + { + std::lock_guard lock(g_module_wrap_mu); + inserted = g_module_wrap_cleanup_hooks.emplace(env).second; + } if (!inserted) return; if (napi_add_env_cleanup_hook(env, CleanupModuleWrapState, env) != napi_ok) { - g_module_wrap_cleanup_hooks.erase(it); + std::lock_guard lock(g_module_wrap_mu); + g_module_wrap_cleanup_hooks.erase(env); } } @@ -921,7 +977,7 @@ void ThrowV8CodeError(v8::Local context, const char* code, const st } ModuleWrapRecord* FindModuleRecordForModule(napi_env env, v8::Local module) { - auto* state = GetModuleWrapState(env); + auto* state = FindModuleWrapState(env); if (state == nullptr || module.IsEmpty()) return nullptr; for (ModuleWrapRecord* record : state->modules) { if (record == nullptr || record->module.IsEmpty()) continue; @@ -933,7 +989,7 @@ ModuleWrapRecord* FindModuleRecordForModule(napi_env env, v8::Local ModuleWrapRecord* FindModuleRecordForHandle(napi_env env, unofficial_napi_module module) { if (env == nullptr || module == nullptr) return nullptr; - auto* state = GetModuleWrapState(env); + auto* state = FindModuleWrapState(env); if (state == nullptr) return nullptr; ModuleWrapRecord* candidate = ModuleRecord(module); auto it = std::find(state->modules.begin(), state->modules.end(), candidate); @@ -1015,10 +1071,53 @@ bool PopulateModuleRequests(napi_env env, v8::Local context, v8::Local module) { if (env == nullptr || record == nullptr || module.IsEmpty()) return false; + auto* state = GetModuleWrapState(env); + if (state == nullptr || state->request_metadata_bytes > kMaxModuleRequestMetadata) + return false; record->module_requests.clear(); record->resolve_cache.clear(); v8::Local raw_requests = module->GetModuleRequests(); + if (raw_requests->Length() < 0 || + static_cast(raw_requests->Length()) > kMaxModuleRequests) + return false; + const size_t available = kMaxModuleRequestMetadata - state->request_metadata_bytes; + size_t estimated_bytes = 0; + auto consume = [&](size_t bytes) { + if (bytes > available - estimated_bytes) return false; + estimated_bytes += bytes; + return true; + }; + auto consume_string = [&](v8::Local value) { + if (value.IsEmpty() || !value->IsString()) return false; + int length = value.As()->Utf8Length(env->isolate); + return length >= 0 && + static_cast(length) <= (available - estimated_bytes) / 4 && + consume(static_cast(length) * 4); + }; + // Count all metadata before allocating any retained std::string or map + // entry. The factor of four covers the request text, serialized lookup key, + // temporary conversion, and allocator capacity; fixed overhead covers nodes + // and vector slack. The per-env sum includes every still-live module. + for (int i = 0; i < raw_requests->Length(); ++i) { + v8::Local value = raw_requests->Get(context, i).As(); + if (value.IsEmpty() || !value->IsModuleRequest() || !consume(256)) + return false; + v8::Local request = value.As(); + if (!consume_string(request->GetSpecifier())) return false; + v8::Local attributes = request->GetImportAttributes(); + if (attributes->Length() < 0 || attributes->Length() % 3 != 0 || + static_cast(attributes->Length() / 3) > kMaxModuleRequests) + return false; + for (int j = 0; j < attributes->Length(); j += 3) { + if (!consume(128) || + !consume_string(attributes->Get(context, j).As()) || + !consume_string(attributes->Get(context, j + 1).As())) + return false; + } + } + record->request_metadata_bytes = estimated_bytes; + state->request_metadata_bytes += estimated_bytes; record->module_requests.reserve(raw_requests->Length()); for (int i = 0; i < raw_requests->Length(); ++i) { v8::Local request_value = raw_requests->Get(context, i).As(); @@ -1169,6 +1268,15 @@ v8::Local CreateDynamicImportAttributesObject( v8::Local import_attributes) { v8::Isolate* isolate = env->isolate; v8::Local context = env->context(); + // Four native vectors are materialized below and in + // CreateFrozenNullProtoObject. Bound this transient metadata before reserve. + if (import_attributes->Length() < 0 || + import_attributes->Length() > kMaxDynamicImportAttributeEntries || + import_attributes->Length() % 2 != 0) { + isolate->ThrowException(v8::Exception::RangeError( + OneByteString(isolate, "too many dynamic import attributes"))); + return {}; + } std::vector> names; std::vector> values; names.reserve(import_attributes->Length() / 2); @@ -1193,7 +1301,7 @@ v8::MaybeLocal ImportModuleDynamicallyWithPhase( napi_env env = GetModuleWrapEnvForIsolate(context->GetIsolate()); if (env == nullptr) return v8::MaybeLocal(); - auto* state = GetModuleWrapState(env); + auto* state = FindModuleWrapState(env); if (state == nullptr) return v8::MaybeLocal(); napi_value callback = GetRefValue(env, state->import_module_dynamically_ref); if (callback == nullptr) return v8::MaybeLocal(); @@ -1201,6 +1309,8 @@ v8::MaybeLocal ImportModuleDynamicallyWithPhase( v8::Isolate* isolate = context->GetIsolate(); v8::EscapableHandleScope handle_scope(isolate); v8::Context::Scope context_scope(context); + NapiV8ProviderPromiseReservation reservation(isolate); + if (!reservation.acquired()) return v8::MaybeLocal(); v8::Local id = v8::Undefined(isolate); bool have_host_defined_options = false; @@ -1227,11 +1337,14 @@ v8::MaybeLocal ImportModuleDynamicallyWithPhase( napi_value phase_value = nullptr; napi_create_int32(env, phase == v8::ModuleImportPhase::kSource ? 1 : 2, &phase_value); + v8::Local attributes = + CreateDynamicImportAttributesObject(env, import_attributes); + if (attributes.IsEmpty()) return v8::MaybeLocal(); napi_value argv[5] = { napi_v8_wrap_value(env, id), napi_v8_wrap_value(env, specifier), phase_value, - napi_v8_wrap_value(env, CreateDynamicImportAttributesObject(env, import_attributes)), + napi_v8_wrap_value(env, attributes), napi_v8_wrap_value(env, resource_name), }; @@ -1257,7 +1370,9 @@ v8::MaybeLocal ImportModuleDynamicallyWithPhase( return v8::MaybeLocal(); } v8::Local promise = resolver->GetPromise(); - NapiV8TrackProviderPromise(isolate, promise); + if (!NapiV8TrackProviderPromise(isolate, promise)) { + return v8::MaybeLocal(); + } return handle_scope.Escape(promise); } @@ -1276,12 +1391,12 @@ void HostInitializeImportMetaObject(v8::Local context, v8::Local meta) { napi_env env = GetModuleWrapEnvForIsolate(context->GetIsolate()); if (env == nullptr) return; - auto it = g_module_wrap_states.find(env); - if (it == g_module_wrap_states.end()) return; + auto* state = FindModuleWrapState(env); + if (state == nullptr) return; ModuleWrapRecord* record = FindModuleRecordForModule(env, module); if (record == nullptr) return; - napi_value callback = GetRefValue(env, it->second.initialize_import_meta_ref); + napi_value callback = GetRefValue(env, state->initialize_import_meta_ref); napi_value wrapper = GetRefValue(env, record->wrapper_ref); napi_value id_value = GetRefValue(env, record->host_defined_option_ref); if (callback == nullptr || wrapper == nullptr || id_value == nullptr) return; @@ -1300,7 +1415,7 @@ v8::MaybeLocal LinkRequiredFacadeOriginal(v8::Local con v8::Local /*referrer*/) { napi_env env = GetModuleWrapEnvForIsolate(context->GetIsolate()); if (env == nullptr) return v8::MaybeLocal(); - auto* state = GetModuleWrapState(env); + auto* state = FindModuleWrapState(env); if (state == nullptr || state->temporary_required_module_facade_original == nullptr || state->temporary_required_module_facade_original->module.IsEmpty()) { return v8::MaybeLocal(); @@ -1490,10 +1605,19 @@ bool ReadParamsArray(napi_env env, napi_value params_or_undefined, std::vectorIsArray()) return false; v8::Local context = env->context(); v8::Local array = value.As(); - out->reserve(array->Length()); - for (uint32_t i = 0; i < array->Length(); ++i) { + constexpr uint32_t kMaxParams = 1024; + constexpr size_t kMaxParamBytes = 1024 * 1024; + const uint32_t length = array->Length(); + if (length > kMaxParams) return false; + out->reserve(length); + size_t total_bytes = 0; + for (uint32_t i = 0; i < length; ++i) { v8::Local item; if (!array->Get(context, i).ToLocal(&item) || !item->IsString()) return false; + const int bytes = item.As()->Utf8Length(env->isolate); + if (bytes < 0 || static_cast(bytes) > kMaxParamBytes - total_bytes) + return false; + total_bytes += static_cast(bytes); out->push_back(V8ValueToUtf8(env->isolate, item)); } return true; @@ -1645,17 +1769,21 @@ bool NapiV8IsContextifyContext(napi_env env, v8::Local context) { void NapiV8ApplyPromiseHooksToContextifyContexts(napi_env env) { if (env == nullptr || env->isolate == nullptr) return; - - std::lock_guard lock(g_context_mu); - auto it = g_context_records.find(env); - if (it == g_context_records.end()) return; - - for (auto& rec : it->second) { - v8::Local context = rec.context.Get(env->isolate); - if (!context.IsEmpty()) { - NapiV8ApplyPromiseHooksToContext(env, context); + v8::HandleScope scope(env->isolate); + std::vector> contexts; + { + std::lock_guard lock(g_context_mu); + auto it = g_context_records.find(env); + if (it == g_context_records.end()) return; + contexts.reserve(it->second.size()); + for (auto& rec : it->second) { + v8::Local context = rec.context.Get(env->isolate); + if (!context.IsEmpty()) contexts.push_back(context); } } + // Applying hooks touches g_runtime_mu and can enter V8. Never hold the + // process-wide context registry mutex across either operation. + for (auto context : contexts) NapiV8ApplyPromiseHooksToContext(env, context); } extern "C" { @@ -1673,7 +1801,6 @@ napi_status NAPI_CDECL unofficial_napi_contextify_make_context( if (env == nullptr || sandbox_or_symbol == nullptr || name == nullptr || result_out == nullptr) { return napi_invalid_arg; } - (void)allow_code_gen_wasm; v8::Isolate* isolate = env->isolate; v8::EscapableHandleScope handle_scope(isolate); @@ -1726,7 +1853,7 @@ napi_status NAPI_CDECL unofficial_napi_contextify_make_context( napi_v8_set_last_exception( env, try_catch.Exception(), try_catch.Message()); } - return napi_pending_exception; + return try_catch.HasCaught() ? napi_pending_exception : napi_generic_failure; } maybe_global_object = sandbox_value; } @@ -1745,6 +1872,8 @@ napi_status NAPI_CDECL unofficial_napi_contextify_make_context( context->SetSecurityToken(current->GetSecurityToken()); context->AllowCodeGenerationFromStrings(allow_code_gen_strings); + SetWasmCodeGenerationAllowed( + context, !env->restrict_unmetered_webassembly && allow_code_gen_wasm); NapiV8ApplyPromiseHooksToContext(env, context); v8::Local key_object; @@ -1758,6 +1887,11 @@ napi_status NAPI_CDECL unofficial_napi_contextify_make_context( } } + if (env->restrict_unmetered_webassembly && + !RemoveUnmeteredWebAssembly(context)) { + return napi_pending_exception; + } + napi_value key_napi = napi_v8_wrap_value(env, handle_scope.Escape(key_object)); if (key_napi == nullptr) return napi_generic_failure; @@ -2177,8 +2311,17 @@ napi_status NAPI_CDECL unofficial_napi_contextify_compile_function( return napi_invalid_arg; } - std::string source_text = bytecode_record != nullptr ? bytecode_record->source_utf8 - : ToUtf8String(env, source->text, ""); + std::string source_text; + if (bytecode_record != nullptr) { + source_text = bytecode_record->source_utf8; + } else { + v8::Local source_string = ToV8String(env, source->text, ""); + const int source_bytes = source_string->Utf8Length(isolate); + if (source_bytes < 0 || source_bytes > 4 * 1024 * 1024) + return napi_invalid_arg; + source_text = V8ValueToUtf8(isolate, source_string); + } + if (source_text.size() > 4 * 1024 * 1024) return napi_invalid_arg; if (StartsWithBomHashbang(source_text)) { v8::Local message = v8::String::NewFromUtf8Literal( isolate, "Invalid or unexpected token"); @@ -2193,6 +2336,8 @@ napi_status NAPI_CDECL unofficial_napi_contextify_compile_function( } v8::Local filename_str = ToV8String(env, filename, ""); + if (filename_str->Utf8Length(isolate) > 1024 * 1024) + return napi_invalid_arg; v8::Local host_id_symbol; if (host_defined_option_id != nullptr) { @@ -2207,8 +2352,10 @@ napi_status NAPI_CDECL unofficial_napi_contextify_compile_function( v8::Local value = napi_v8_unwrap_value(context_extensions_or_undefined); if (value.IsEmpty() || !value->IsArray()) return napi_invalid_arg; v8::Local array = value.As(); - context_extensions.reserve(array->Length()); - for (uint32_t i = 0; i < array->Length(); ++i) { + const uint32_t length = array->Length(); + if (length > 1024) return napi_invalid_arg; + context_extensions.reserve(length); + for (uint32_t i = 0; i < length; ++i) { v8::Local item; if (!array->Get(current, i).ToLocal(&item) || !item->IsObject()) return napi_invalid_arg; context_extensions.push_back(item.As()); @@ -2393,6 +2540,8 @@ static napi_status CreateSourceTextModule( *module_out = nullptr; EnsureModuleWrapCleanupHook(env); + if (GetModuleWrapState(env)->modules.size() >= kMaxLiveModuleWraps) + return napi_generic_failure; v8::Isolate* isolate = env->isolate; v8::HandleScope handle_scope(isolate); v8::Local context = @@ -2503,10 +2652,11 @@ static napi_status CreateSourceTextModule( return napi_generic_failure; } - { - std::lock_guard lock(g_module_wrap_mu); - GetModuleWrapState(env)->modules.push_back(record); + if (GetModuleWrapState(env)->modules.size() >= kMaxLiveModuleWraps) { + DestroyModuleRecord(record); + return napi_generic_failure; } + GetModuleWrapState(env)->modules.push_back(record); *module_out = ModuleHandle(record); return napi_ok; } @@ -2529,6 +2679,8 @@ static napi_status CreateSyntheticModule( if (napi_is_array(env, export_names, &is_array) != napi_ok || !is_array) return napi_invalid_arg; EnsureModuleWrapCleanupHook(env); + if (GetModuleWrapState(env)->modules.size() >= kMaxLiveModuleWraps) + return napi_generic_failure; v8::Isolate* isolate = env->isolate; v8::HandleScope handle_scope(isolate); v8::Local context = @@ -2539,6 +2691,7 @@ static napi_status CreateSyntheticModule( uint32_t export_count = 0; napi_get_array_length(env, export_names, &export_count); + if (export_count > 1024) return napi_invalid_arg; std::vector> export_names_v8; export_names_v8.reserve(export_count); for (uint32_t i = 0; i < export_count; ++i) { @@ -2562,10 +2715,11 @@ static napi_status CreateSyntheticModule( napi_create_reference(env, wrapper, 1, &record->wrapper_ref); napi_create_reference(env, synthetic_eval_steps, 1, &record->synthetic_eval_steps_ref); - { - std::lock_guard lock(g_module_wrap_mu); - GetModuleWrapState(env)->modules.push_back(record); + if (GetModuleWrapState(env)->modules.size() >= kMaxLiveModuleWraps) { + DestroyModuleRecord(record); + return napi_generic_failure; } + GetModuleWrapState(env)->modules.push_back(record); *module_out = ModuleHandle(record); return napi_ok; } @@ -2626,14 +2780,10 @@ napi_status NAPI_CDECL unofficial_napi_module_wrap_destroy( unofficial_napi_module module) { if (env == nullptr || module == nullptr) return napi_invalid_arg; ModuleWrapRecord* record = ModuleRecord(module); - { - std::lock_guard lock(g_module_wrap_mu); - auto it = g_module_wrap_states.find(env); - if (it == g_module_wrap_states.end()) return napi_ok; - auto& modules = it->second.modules; - if (std::find(modules.begin(), modules.end(), record) == modules.end()) { - return napi_ok; - } + auto* state = FindModuleWrapState(env); + if (state == nullptr || + std::find(state->modules.begin(), state->modules.end(), record) == state->modules.end()) { + return napi_ok; } DestroyModuleRecord(record); return napi_ok; @@ -2690,29 +2840,36 @@ napi_status NAPI_CDECL unofficial_napi_module_wrap_link( const unofficial_napi_module* linked_modules) { if (env == nullptr || module == nullptr) return napi_invalid_arg; ModuleWrapRecord* record = ModuleRecord(module); + if (count > kMaxModuleRequests) return napi_invalid_arg; if (count != record->module_requests.size()) { ThrowCodeError(env, "ERR_VM_MODULE_LINK_FAILURE", "linked modules array length mismatch"); return napi_pending_exception; } - record->linked_requests.assign(count, nullptr); - for (size_t i = 0; i < count; ++i) { - ModuleWrapRecord* linked = - linked_modules != nullptr ? ModuleRecord(linked_modules[i]) : nullptr; - if (linked == nullptr) { - ThrowCodeError(env, "ERR_VM_MODULE_LINK_FAILURE", "linked module missing"); - return napi_pending_exception; - } - record->linked_requests[i] = linked; - const std::string key = - SerializeModuleRequestKey(record->module_requests[i].specifier, record->module_requests[i].attributes); - auto it = record->resolve_cache.find(key); - if (it != record->resolve_cache.end() && it->second < i && record->linked_requests[it->second] != linked) { - ThrowCodeError(env, - "ERR_MODULE_LINK_MISMATCH", - "Module request '" + record->module_requests[i].specifier + "' must be linked to the same module"); - return napi_pending_exception; + try { + std::vector linked_requests(count, nullptr); + for (size_t i = 0; i < count; ++i) { + ModuleWrapRecord* linked = + linked_modules != nullptr ? ModuleRecord(linked_modules[i]) : nullptr; + if (linked == nullptr) { + ThrowCodeError(env, "ERR_VM_MODULE_LINK_FAILURE", "linked module missing"); + return napi_pending_exception; + } + linked_requests[i] = linked; + const std::string key = SerializeModuleRequestKey( + record->module_requests[i].specifier, record->module_requests[i].attributes); + auto it = record->resolve_cache.find(key); + if (it != record->resolve_cache.end() && it->second < i && + linked_requests[it->second] != linked) { + ThrowCodeError(env, + "ERR_MODULE_LINK_MISMATCH", + "Module request '" + record->module_requests[i].specifier + "' must be linked to the same module"); + return napi_pending_exception; + } } + record->linked_requests.swap(linked_requests); + } catch (const std::bad_alloc&) { + return napi_generic_failure; } return napi_ok; } @@ -3006,7 +3163,6 @@ napi_status NAPI_CDECL unofficial_napi_module_wrap_set_hooks( } EnsureModuleWrapCleanupHook(env); - std::lock_guard lock(g_module_wrap_mu); auto* state = GetModuleWrapState(env); ResetRef(env, &state->import_module_dynamically_ref); ResetRef(env, &state->initialize_import_meta_ref); @@ -3046,15 +3202,9 @@ napi_status NAPI_CDECL unofficial_napi_module_wrap_create_required_module_facade return napi_pending_exception; } - { - std::lock_guard lock(g_module_wrap_mu); - GetModuleWrapState(env)->temporary_required_module_facade_original = record; - } + GetModuleWrapState(env)->temporary_required_module_facade_original = record; const bool instantiated = facade->InstantiateModule(context, LinkRequiredFacadeOriginal).FromMaybe(false); - { - std::lock_guard lock(g_module_wrap_mu); - GetModuleWrapState(env)->temporary_required_module_facade_original = nullptr; - } + GetModuleWrapState(env)->temporary_required_module_facade_original = nullptr; if (!instantiated) return napi_pending_exception; v8::Local evaluated; @@ -3065,25 +3215,68 @@ napi_status NAPI_CDECL unofficial_napi_module_wrap_create_required_module_facade } // extern "C" -void NapiV8TrackProviderPromise(v8::Isolate* isolate, - v8::Local promise) { - if (isolate == nullptr || promise.IsEmpty()) return; +NapiV8ProviderPromiseReservation::NapiV8ProviderPromiseReservation( + v8::Isolate* isolate) + : isolate_(isolate), acquired_(true), reserved_(false) { + if (isolate == nullptr) return; napi_env env = GetModuleWrapEnvForIsolate(isolate); if (env == nullptr) return; - std::lock_guard lock(g_module_wrap_mu); - auto it = g_module_wrap_states.find(env); - if (it != g_module_wrap_states.end()) { - it->second.pending_dynamic_imports.emplace_back(isolate, promise); + auto* state = FindModuleWrapState(env); + if (state == nullptr) return; + if (state->pending_dynamic_imports.size() + + state->pending_dynamic_imports_in_flight >= + kMaxPendingProviderPromises) { + NapiV8HasPendingProviderWork(env); + if (state->pending_dynamic_imports.size() + + state->pending_dynamic_imports_in_flight >= + kMaxPendingProviderPromises) { + acquired_ = false; + isolate->ThrowException(v8::Exception::RangeError( + OneByteString(isolate, "too many pending dynamic imports"))); + return; + } + } + ++state->pending_dynamic_imports_in_flight; + reserved_ = true; +} + +NapiV8ProviderPromiseReservation::~NapiV8ProviderPromiseReservation() { + if (!reserved_) return; + napi_env env = GetModuleWrapEnvForIsolate(isolate_); + if (env == nullptr) return; + auto* state = FindModuleWrapState(env); + if (state != nullptr && state->pending_dynamic_imports_in_flight != 0) { + --state->pending_dynamic_imports_in_flight; + } +} + +bool NapiV8TrackProviderPromise(v8::Isolate* isolate, + v8::Local promise) { + if (isolate == nullptr || promise.IsEmpty()) return true; + napi_env env = GetModuleWrapEnvForIsolate(isolate); + if (env == nullptr) return true; + auto* state = FindModuleWrapState(env); + if (state == nullptr) return true; + if (state->pending_dynamic_imports.size() >= kMaxPendingProviderPromises) { + NapiV8HasPendingProviderWork(env); + if (state->pending_dynamic_imports.size() >= kMaxPendingProviderPromises) { + // Dropping tracking would let the instance report quiescence with guest + // module work still alive. End this isolate instead. + isolate->ThrowException(v8::Exception::RangeError( + OneByteString(isolate, "too many pending dynamic imports"))); + return false; + } } + state->pending_dynamic_imports.emplace_back(isolate, promise); + return true; } bool NapiV8HasPendingProviderWork(napi_env env) { if (env == nullptr || env->isolate == nullptr) return false; - std::lock_guard lock(g_module_wrap_mu); - auto it = g_module_wrap_states.find(env); - if (it == g_module_wrap_states.end()) return false; + auto* state = FindModuleWrapState(env); + if (state == nullptr) return false; - auto& imports = it->second.pending_dynamic_imports; + auto& imports = state->pending_dynamic_imports; size_t write_index = 0; for (size_t index = 0; index < imports.size(); ++index) { auto& tracked = imports[index];