diff --git a/docs/atlas/catalog.json b/docs/atlas/catalog.json index 07a10ee3..6d344a7b 100644 --- a/docs/atlas/catalog.json +++ b/docs/atlas/catalog.json @@ -1 +1 @@ -{"records":["0863093e69a16355fa9b90f8e7e29156ea4ad88c4523bc4883568bde831bdf35","2056fa8b653c2240afdad2a16f572a07019109254847829610a9468bb2f95248","231df3792931ad2333d00970a5993b3e13fa9dc38f687899f9fa2c966ed9fbfa","24fdc3980640ba58bedcae6f16d5b467eb7383c31161fb9b6ff10fd2e667b0e5","264a2f7887a9b6473cbc24102e1bf9be70e57f6d7ba224138140efa094d541f4","2b7dfb3a2f76368cb603161cdb6f8fa8c2b8afc3e96659dd37c975921ba5ba66","33df463263533529cca24a0b09937573f836d197cc1a739e9960b1cdd4deb1fd","343077d02ddd266b21d8677f7c256854134f7e3e2d18137b55ebe3f32564c5c0","34391bda3fb56135a2bddf74ab790958c175bc6a69c38175e6f8db792cfd50a0","36a19fefd7024f54f3391ffed19019c10222a514719edd7a44f0cde2a1c48258","3d3a0a302c02cd0bfee86715d09b0211baa8dad3e47402c638fc95c7cfa02e87","3de8718fb3ca9b15212e8bd84fba205995d24636087090a61c63c28a96a745b1","3df4f31fda99ad137c651322ad29809e36380400516399274870b4a4332b4a92","3f09ee199e3c2e808e0f6e74f197f2fdd8158ae099a7195c498ce9466bb78b03","44bda82d6f9a9625a83283bf56a15a1831f287037214dc134eeeae87f1faa869","6c5b38ee260d1f15f8029d699ec1cebceafb5e1407a6341dc7a4b96f4d9d3521","72742cbaa561237f10251e94aa5c61153ab5c7f14fc5771b7a5ebdece1e1cb11","87d6de6f3730cecd75d9a4411bf4dbac4e058127a3ce9f46b07f6de110bc26de","8b1cf51c5f03c0ae19c46ca3ea4a1158acbb6e47bf109ca8c506c5417b51cf9f","95115e1052b5ea4fcd393f5c9b9c6f0298657f0028eee84e16215ede97576cfa","a38737476856236f33e0aef1b97e2b900677cf99f1dbcfd381c708ceeca8b7f3","ab4502eff76606ac8e58b623878f3d76e1e0b18f30796aec7c446e2aab8b57c8","c01c2c98f13a65b49113916a2f295e46a03fc4c185ece880267dd86c3f3dee8b","d557525e3ebfad227c8d6db9bb6350d42506fb02b1de8058cb443f4f008c0f67"],"schema":"fsgg.atlas/1"} +{"records":["049f1d0d8155f65b6f7675ebdb200d4595570cc7d585dd74876e0285eceffed6","0863093e69a16355fa9b90f8e7e29156ea4ad88c4523bc4883568bde831bdf35","2056fa8b653c2240afdad2a16f572a07019109254847829610a9468bb2f95248","231df3792931ad2333d00970a5993b3e13fa9dc38f687899f9fa2c966ed9fbfa","24fdc3980640ba58bedcae6f16d5b467eb7383c31161fb9b6ff10fd2e667b0e5","264a2f7887a9b6473cbc24102e1bf9be70e57f6d7ba224138140efa094d541f4","2b7dfb3a2f76368cb603161cdb6f8fa8c2b8afc3e96659dd37c975921ba5ba66","33df463263533529cca24a0b09937573f836d197cc1a739e9960b1cdd4deb1fd","343077d02ddd266b21d8677f7c256854134f7e3e2d18137b55ebe3f32564c5c0","34391bda3fb56135a2bddf74ab790958c175bc6a69c38175e6f8db792cfd50a0","36a19fefd7024f54f3391ffed19019c10222a514719edd7a44f0cde2a1c48258","3d3a0a302c02cd0bfee86715d09b0211baa8dad3e47402c638fc95c7cfa02e87","3de8718fb3ca9b15212e8bd84fba205995d24636087090a61c63c28a96a745b1","3df4f31fda99ad137c651322ad29809e36380400516399274870b4a4332b4a92","3f09ee199e3c2e808e0f6e74f197f2fdd8158ae099a7195c498ce9466bb78b03","44bda82d6f9a9625a83283bf56a15a1831f287037214dc134eeeae87f1faa869","6c5b38ee260d1f15f8029d699ec1cebceafb5e1407a6341dc7a4b96f4d9d3521","72742cbaa561237f10251e94aa5c61153ab5c7f14fc5771b7a5ebdece1e1cb11","85f06991da74fa4e59ba73c7034dc53a35a4e90c5b64d83cc8956d3afd61d376","87d6de6f3730cecd75d9a4411bf4dbac4e058127a3ce9f46b07f6de110bc26de","8b1cf51c5f03c0ae19c46ca3ea4a1158acbb6e47bf109ca8c506c5417b51cf9f","95115e1052b5ea4fcd393f5c9b9c6f0298657f0028eee84e16215ede97576cfa","a38737476856236f33e0aef1b97e2b900677cf99f1dbcfd381c708ceeca8b7f3","ab4502eff76606ac8e58b623878f3d76e1e0b18f30796aec7c446e2aab8b57c8","c01c2c98f13a65b49113916a2f295e46a03fc4c185ece880267dd86c3f3dee8b","d557525e3ebfad227c8d6db9bb6350d42506fb02b1de8058cb443f4f008c0f67","d9718eb12e9ae9ea3a29a9904f131f53e99c584ef1b297aa05f1f8f6cdc6ab2a"],"schema":"fsgg.atlas/1"} diff --git a/docs/atlas/records/049f1d0d8155f65b6f7675ebdb200d4595570cc7d585dd74876e0285eceffed6.json b/docs/atlas/records/049f1d0d8155f65b6f7675ebdb200d4595570cc7d585dd74876e0285eceffed6.json new file mode 100644 index 00000000..83179582 --- /dev/null +++ b/docs/atlas/records/049f1d0d8155f65b6f7675ebdb200d4595570cc7d585dd74876e0285eceffed6.json @@ -0,0 +1 @@ +{"citations":[{"path":"docs/roadmaps/wasm-shared-01.md","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"815afcb0fa6ab12251112d1bee3dd293699783c88a6ed70ba1d98ef491130649"},{"path":"scripts/wasm-release/build-worker-policy.sh","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"3e767ab1a920c3bc84c9c56a0d7eeef89e3c06f861d945f4b3523426337a945e"},{"path":"scripts/wasm-release/worker-bundle/build.mjs","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"404803d8f8bf57efc76843776158cce2358f635f311f156decbb451d595c2509"},{"path":"scripts/wasm-release/worker-bundle/closed-module.mjs","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"b5ee7353dbc5fb65edc749401332108ed08945babf746371d8807d08a4a42804"},{"path":"scripts/wasm-release/worker-bundle/package-lock.json","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"14b0f16377cd6a6a6242ffb86201d6819390ab1de32046b49ce23df170643fa4"},{"path":"src/Wasm.Browser/FS.GG.Wasm.Browser.fsproj","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"8ae7bf1d99b06e0d89a8467e4eba00a2c2b8c90f72bfb8a55c11d3223e71af50"},{"path":"src/Wasm.Browser/WorkerEntry.fs","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"34cd39c024c4f5415489947e024a48bee8a44671b39b9b013ca3118bcd21ad9f"},{"path":"src/Wasm.Browser/js/module-worker.mjs","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"81a4a316b1da49793eef3d4f3849cdf94ef6135c9e38f1b2867d887e95a8dea4"},{"path":"src/Wasm.Browser/js/worker-client.mjs","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"9bfcc57b363b4169f96e8b4ea8bb6a6b85a961ceec41c2e0a174ab2e0ce0cfcd"},{"path":"scripts/wasm-release/release_manifest.py","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"16e268ee5ef5b5cf2fdfd160c279a5695dd6ebba8951c56c40ede1129bac68db"},{"path":"tests/Wasm.PackageConsumer/browser/package-consumer.spec.mjs","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"269344d9658e2e3b565003dc6d4bae9d8292369b2aaa85ea1a019887133dc266"},{"path":"tests/Wasm.PackageConsumer/browser/package-consumer.mjs","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"b1d5817a08d1f44635f192d77eac35229f77fd98a56cb7843dd618807eba9666"},{"path":"tests/release/wasm/test-worker-transport.mjs","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"b1b996e7e3e657dd86e79a2aeffce9670526d9ac081cc683771acea0161b98be"},{"path":"tests/release/wasm/test-worker-bundle-custody.py","repository":"FS-GG/FS.GG.Game","revision":"7e77683b472d5da6872260a71287799be9424b46","sha256":"b4ce54c8f0165acf82b5a4655037fea918ade2cabe46008c10a22aa54da9923a"},{"path":"lib/shared/types.ts","repository":"evanw/esbuild","revision":"609683d892977362a0f99026cb74b96263d728a9","sha256":"e078310f23f0ccfa28a6ac81b5fad81f27150e921f92ab6e648caab42e2d9a14"},{"path":"packages/eslint-scope/lib/index.js","repository":"eslint/js","revision":"a73b94be20835cd52379a43629fa2225e3f18763","sha256":"522ad28c80b2749adc5d388703bc7f0dd919808fea20cbd84cdf473734d4748f"}],"conflictsWith":[],"id":"game/closed-worker-build-boundary","kind":"architecture","recordedAt":"2026-10-17T21:04:54Z","repository":"FS-GG/FS.GG.Game","schema":"fsgg.atlas/1","status":"recorded","summary":"Current source prepares coherent 0.5.0 without altering authentic published 0.4.0/P6732 or its distinct accepted installed qualifier. The maintained isolated Fable build feeds exact captured generated-policy and mechanical-entry buffers to pinned build-only esbuild 0.28.2 twice. It selects one deterministic non-minified ESM Worker, checks actual output syntax and lexical unresolved loader/execution/network references using Acorn 8.19.0 and ESLint scope 9.1.2, and rejects runtime module imports. Locally bound admission require is legitimate, not a Node loader; this closure check is not an arbitrary-JavaScript sandbox. Tool dependencies install outside source and the raw-policy package glob with dependency scripts disabled. Package custody retains raw generated source, mechanical entry, exact tool lock, input hashes and final bundle hash, so runtime selects the closed artifact rather than a source-graph fallback. The byte reader establishes correspondence, not independent compiler or signature attestation. Optional bounded scalar observations distinguish mechanical handler readiness, actual admission, SHA and combined native instantiate boundaries and page transport calls. Advisory packets never enter the lifecycle reducer; Worker-local offsets and page clocks remain separate, with missing/truncated/read-failed evidence explicit. Passive observer overhead is inside the existing budget and cannot prove an earlier timeout cause. Original pre-create enclosing deadline, identities, limits, lifecycle and termination remain selected; no ready-gated clock reset, priming, retry, cache workaround or consumer rewrite. A source-derived retired-physical-Worker onerror guard also prevents borrowing a new same-name command; it is not attributed as a prior failure cause. Prepared installed controls require fresh initial and abort-to-new physical candidates under 250ms, one-millisecond cold expiry, and a genuine loaded message held past expiry then released as an explicitly injected delivery impairment. Twelve inert actual transport controls, three synthetic byte-custody tests, fourteen existing installed-evidence tests, seven selected promotion tests and static/version guards passed locally; real F#/Fable/esbuild/closure-tool/browser controls and original full 35-minute hosted fit remain unrun. Existing publication/installed/native/product acceptance is not transferred. Future 0.5 publication and finite installed binding need actual new P/readback; no operation is selected by this record. Public rationale derives only from producer source and does not export private consumer findings.","supersedes":[],"tags":["wasm","worker","bundle","startup","diagnostics","custody","current-only"],"title":"Prepared closed Worker successor and bounded startup observations"} diff --git a/docs/atlas/records/85f06991da74fa4e59ba73c7034dc53a35a4e90c5b64d83cc8956d3afd61d376.json b/docs/atlas/records/85f06991da74fa4e59ba73c7034dc53a35a4e90c5b64d83cc8956d3afd61d376.json new file mode 100644 index 00000000..75bb472b --- /dev/null +++ b/docs/atlas/records/85f06991da74fa4e59ba73c7034dc53a35a4e90c5b64d83cc8956d3afd61d376.json @@ -0,0 +1 @@ +{"citations":[{"path":"docs/roadmaps/wasm-shared-01.md","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"5e20b4d379cb6ef521f73291854e35538a09b8f758141e8f6b058c4bc1b0bf69"},{"path":"scripts/wasm-release/build-worker-policy.sh","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"3e767ab1a920c3bc84c9c56a0d7eeef89e3c06f861d945f4b3523426337a945e"},{"path":"scripts/wasm-release/worker-bundle/build.mjs","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"404803d8f8bf57efc76843776158cce2358f635f311f156decbb451d595c2509"},{"path":"scripts/wasm-release/worker-bundle/closed-module.mjs","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"8511d26e35a0c1468511db735d0179e3b699a55e72ab961cfb34314576080f77"},{"path":"scripts/wasm-release/worker-bundle/package-lock.json","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"14b0f16377cd6a6a6242ffb86201d6819390ab1de32046b49ce23df170643fa4"},{"path":"src/Wasm.Browser/FS.GG.Wasm.Browser.fsproj","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"8ae7bf1d99b06e0d89a8467e4eba00a2c2b8c90f72bfb8a55c11d3223e71af50"},{"path":"src/Wasm.Browser/WorkerEntry.fs","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"34cd39c024c4f5415489947e024a48bee8a44671b39b9b013ca3118bcd21ad9f"},{"path":"src/Wasm.Browser/js/module-worker.mjs","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"81a4a316b1da49793eef3d4f3849cdf94ef6135c9e38f1b2867d887e95a8dea4"},{"path":"src/Wasm.Browser/js/worker-client.mjs","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"9bfcc57b363b4169f96e8b4ea8bb6a6b85a961ceec41c2e0a174ab2e0ce0cfcd"},{"path":"scripts/wasm-release/release_manifest.py","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"d1035834fed0950169dd3f5c14b5ee37aebed5171a2785a43d14fb5ceb8cfbde"},{"path":"tests/Wasm.PackageConsumer/browser/package-consumer.spec.mjs","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"269344d9658e2e3b565003dc6d4bae9d8292369b2aaa85ea1a019887133dc266"},{"path":"tests/Wasm.PackageConsumer/browser/package-consumer.mjs","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"b1d5817a08d1f44635f192d77eac35229f77fd98a56cb7843dd618807eba9666"},{"path":"tests/release/wasm/test-worker-transport.mjs","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"b1b996e7e3e657dd86e79a2aeffce9670526d9ac081cc683771acea0161b98be"},{"path":"tests/release/wasm/test-worker-bundle-custody.py","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"8707e7398f3efec2cd0f08bf065ccc35b1317d6cf0edb0b92a9fdf78ea9aad3d"},{"path":"lib/shared/types.ts","repository":"evanw/esbuild","revision":"609683d892977362a0f99026cb74b96263d728a9","sha256":"e078310f23f0ccfa28a6ac81b5fad81f27150e921f92ab6e648caab42e2d9a14"},{"path":"scripts/wasm-release/worker-bundle/closed-module.test.mjs","repository":"FS-GG/FS.GG.Game","revision":"09f14435f6849e778a199a73e808e810f03a3588","sha256":"77c3b4a57b0413c6c326378bcfec3795626224c6511781398b6d9cf89fa8779c"}],"conflictsWith":[],"id":"game/closed-worker-build-boundary","kind":"architecture","recordedAt":"2026-10-17T21:08:10Z","repository":"FS-GG/FS.GG.Game","schema":"fsgg.atlas/1","status":"recorded","summary":"Current source prepares coherent 0.5.0 without altering authentic published 0.4.0/P6732 or its distinct accepted installed qualifier. The maintained isolated Fable build feeds exact captured generated-policy and mechanical-entry buffers to pinned build-only esbuild 0.28.2 twice. It selects one deterministic non-minified ESM Worker, checks actual output syntax and lexical unresolved loader/execution/network references using Acorn 8.19.0 and ESLint scope 9.1.2, and rejects runtime module imports. Locally bound admission require is legitimate, not a Node loader; this closure check is not an arbitrary-JavaScript sandbox. Tool dependencies install outside source and the raw-policy package glob with dependency scripts disabled. Package custody retains raw generated source, mechanical entry, exact tool lock, input hashes and final bundle hash, so runtime selects the closed artifact rather than a source-graph fallback. The byte reader establishes correspondence, not independent compiler or signature attestation. Optional bounded scalar observations distinguish mechanical handler readiness, actual admission, SHA and combined native instantiate boundaries and page transport calls. Advisory packets never enter the lifecycle reducer; Worker-local offsets and page clocks remain separate, with missing/truncated/read-failed evidence explicit. Passive observer overhead is inside the existing budget and cannot prove an earlier timeout cause. Original pre-create enclosing deadline, identities, limits, lifecycle and termination remain selected; no ready-gated clock reset, priming, retry, cache workaround or consumer rewrite. A source-derived retired-physical-Worker onerror guard also prevents borrowing a new same-name command; it is not attributed as a prior failure cause. Prepared installed controls require fresh initial and abort-to-new physical candidates under 250ms, one-millisecond cold expiry, and a genuine loaded message held past expiry then released as an explicitly injected delivery impairment. Twelve inert actual transport controls, three synthetic byte-custody tests, fourteen existing installed-evidence tests, nine selected promotion tests and static/version guards passed locally; real F#/Fable/esbuild/closure-tool/browser controls and original full 35-minute hosted fit remain unrun. Existing publication/installed/native/product acceptance is not transferred. Future 0.5 publication and finite installed binding need actual new P/readback; no operation is selected by this record. Public rationale derives only from producer source and does not export private consumer findings. Independent source review found two failclosed correspondence gaps before execution: a direct free-global check missed alias/destructuring access, and the package reader checked only the syntax of the recorded tree. The selected repair follows lexical const aliases, permits only fixed mechanical self members and the actual Fable Encoding Buffer member, refuses escaped/dynamic/other host-global access, and prepares alias/destructure/escape negative controls. Source preparation and manifest verification now pass their exact outer tree into the Worker reader; a changed-valid-tree mutant was actually refused by the inert reader tests. No actual generated artifact or runtime failure is attributed to these source findings. The historical finite published-binder fixture now explicitly qualifies only real 0.4 and refuses source 0.5; its actual clean-checkout controls passed. Independent connected source review passed at 09f1443, but does not execute or qualify the tool/Worker paths.","supersedes":["049f1d0d8155f65b6f7675ebdb200d4595570cc7d585dd74876e0285eceffed6"],"tags":["wasm","worker","bundle","startup","diagnostics","custody","current-only"],"title":"Prepared closed Worker successor and bounded startup observations"} diff --git a/docs/atlas/records/d9718eb12e9ae9ea3a29a9904f131f53e99c584ef1b297aa05f1f8f6cdc6ab2a.json b/docs/atlas/records/d9718eb12e9ae9ea3a29a9904f131f53e99c584ef1b297aa05f1f8f6cdc6ab2a.json new file mode 100644 index 00000000..a4e9f706 --- /dev/null +++ b/docs/atlas/records/d9718eb12e9ae9ea3a29a9904f131f53e99c584ef1b297aa05f1f8f6cdc6ab2a.json @@ -0,0 +1 @@ +{"citations":[{"path":"docs/roadmaps/wasm-shared-01.md","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"7a30706cb759924af4d4b4c6980407ac45cb751dcdce59c58556c48be2106acf"},{"path":"scripts/wasm-release/build-worker-policy.sh","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"3e767ab1a920c3bc84c9c56a0d7eeef89e3c06f861d945f4b3523426337a945e"},{"path":"scripts/wasm-release/worker-bundle/build.mjs","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"404803d8f8bf57efc76843776158cce2358f635f311f156decbb451d595c2509"},{"path":"scripts/wasm-release/worker-bundle/closed-module.mjs","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"8511d26e35a0c1468511db735d0179e3b699a55e72ab961cfb34314576080f77"},{"path":"scripts/wasm-release/worker-bundle/package-lock.json","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"14b0f16377cd6a6a6242ffb86201d6819390ab1de32046b49ce23df170643fa4"},{"path":"src/Wasm.Browser/WorkerEntry.fs","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"34cd39c024c4f5415489947e024a48bee8a44671b39b9b013ca3118bcd21ad9f"},{"path":"src/Wasm.Browser/js/module-worker.mjs","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"81a4a316b1da49793eef3d4f3849cdf94ef6135c9e38f1b2867d887e95a8dea4"},{"path":"src/Wasm.Browser/js/worker-client.mjs","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"9bfcc57b363b4169f96e8b4ea8bb6a6b85a961ceec41c2e0a174ab2e0ce0cfcd"},{"path":"scripts/wasm-release/release_manifest.py","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"d1035834fed0950169dd3f5c14b5ee37aebed5171a2785a43d14fb5ceb8cfbde"},{"path":"tests/Wasm.PackageConsumer/browser/package-consumer.spec.mjs","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"269344d9658e2e3b565003dc6d4bae9d8292369b2aaa85ea1a019887133dc266"},{"path":"tests/release/wasm/test-worker-transport.mjs","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"b1b996e7e3e657dd86e79a2aeffce9670526d9ac081cc683771acea0161b98be"},{"path":"lib/shared/types.ts","repository":"evanw/esbuild","revision":"609683d892977362a0f99026cb74b96263d728a9","sha256":"e078310f23f0ccfa28a6ac81b5fad81f27150e921f92ab6e648caab42e2d9a14"},{"path":"scripts/wasm-release/worker-bundle/closed-module.test.mjs","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"77c3b4a57b0413c6c326378bcfec3795626224c6511781398b6d9cf89fa8779c"},{"path":"scripts/verify-wasm-workflow.sh","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"65fbc7d304e80e27a7fa99bb97ff6d5bca840505821213d3fabf624ada19a79f"},{"path":"tests/Wasm.Lifecycle.Tests/packages.lock.json","repository":"FS-GG/FS.GG.Game","revision":"6e8e1110a50d0fd97f5fcab01224b2f4c7447e91","sha256":"61d0c74c684e1576b5729da2905ea638a78e2071eca75bc547db2080d968900f"}],"conflictsWith":[],"id":"game/closed-worker-build-boundary","kind":"architecture","recordedAt":"2026-10-17T21:12:49Z","repository":"FS-GG/FS.GG.Game","schema":"fsgg.atlas/1","status":"recorded","summary":"Current source prepares coherent 0.5.0 without altering authentic published 0.4.0/P6732 or its distinct accepted installed qualifier. The maintained isolated Fable build feeds exact captured generated-policy and mechanical-entry buffers to pinned build-only esbuild 0.28.2 twice. It selects one deterministic non-minified ESM Worker, checks actual output syntax and lexical unresolved loader/execution/network references using Acorn 8.19.0 and ESLint scope 9.1.2, and rejects runtime module imports. Locally bound admission require is legitimate, not a Node loader; this closure check is not an arbitrary-JavaScript sandbox. Tool dependencies install outside source and the raw-policy package glob with dependency scripts disabled. Package custody retains raw generated source, mechanical entry, exact tool lock, input hashes and final bundle hash, so runtime selects the closed artifact rather than a source-graph fallback. The byte reader establishes correspondence, not independent compiler or signature attestation. Optional bounded scalar observations distinguish mechanical handler readiness, actual admission, SHA and combined native instantiate boundaries and page transport calls. Advisory packets never enter the lifecycle reducer; Worker-local offsets and page clocks remain separate, with missing/truncated/read-failed evidence explicit. Passive observer overhead is inside the existing budget and cannot prove an earlier timeout cause. Original pre-create enclosing deadline, identities, limits, lifecycle and termination remain selected; no ready-gated clock reset, priming, retry, cache workaround or consumer rewrite. A source-derived retired-physical-Worker onerror guard also prevents borrowing a new same-name command; it is not attributed as a prior failure cause. Prepared installed controls require fresh initial and abort-to-new physical candidates under 250ms, one-millisecond cold expiry, and a genuine loaded message held past expiry then released as an explicitly injected delivery impairment. Twelve inert actual transport controls, three synthetic byte-custody tests, fourteen existing installed-evidence tests, nine selected promotion tests and static/version guards passed locally; real F#/Fable/esbuild/closure-tool/browser controls and original full 35-minute hosted fit remain unrun. Existing publication/installed/native/product acceptance is not transferred. Future 0.5 publication and finite installed binding need actual new P/readback; no operation is selected by this record. Public rationale derives only from producer source and does not export private consumer findings. Independent source review found two failclosed correspondence gaps before execution: a direct free-global check missed alias/destructuring access, and the package reader checked only the syntax of the recorded tree. The selected repair follows lexical const aliases, permits only fixed mechanical self members and the actual Fable Encoding Buffer member, refuses escaped/dynamic/other host-global access, and prepares alias/destructure/escape negative controls. Source preparation and manifest verification now pass their exact outer tree into the Worker reader; a changed-valid-tree mutant was actually refused by the inert reader tests. No actual generated artifact or runtime failure is attributed to these source findings. The historical finite published-binder fixture now explicitly qualifies only real 0.4 and refuses source 0.5; its actual clean-checkout controls passed. Independent connected source review passed at 09f1443, but does not execute or qualify the tool/Worker paths. First actual source qualifier 39069862111/attempt1 failed the existing static candidate guard before compiler, native bundler or browser work: the Lifecycle.Tests Project-type Contracts dependency edge retained 0.4.0. The repair changes only that project metadata edge to selected 0.5.0; external package identities/content hashes and historical package locks stay unchanged. The existing guard now injects the immediately previous published version as its known-bad range. Its actual extracted data-only preflight passed, and all 24 lockfiles plus six Wasm project references were swept; only that current Project edge was stale. Fresh exact-head full hosted qualification remains mandatory; no failed original is reclassified.","supersedes":["85f06991da74fa4e59ba73c7034dc53a35a4e90c5b64d83cc8956d3afd61d376"],"tags":["wasm","worker","bundle","startup","diagnostics","custody","current-only"],"title":"Prepared closed Worker successor and bounded startup observations"} diff --git a/docs/roadmaps/wasm-shared-01.md b/docs/roadmaps/wasm-shared-01.md index 7fa87a8e..f99767e5 100644 --- a/docs/roadmaps/wasm-shared-01.md +++ b/docs/roadmaps/wasm-shared-01.md @@ -1,5 +1,61 @@ # WASM-SHARED-01 — producer qualification successor +## Current 0.5 Worker packaging source preparation + +The current source successor selects coherent `0.5.0` for the pair and SDK. +Published `0.4.0` and its finite P/Q installed qualification below remain unchanged. +The generated Worker policy currently forms an ESM import graph that must evaluate +before the native entry installs its command handler. Request admission already +sets the deadline before creating that Worker. A later ready event cannot move +the start of the operation budget. + +The maintained `build-worker-policy.sh` route now prepares one closed ESM Worker +from the actual generated F# policy and mechanical entry. Exact locked build-only +esbuild, Acorn and ESLint scope tools preserve names, avoid minification and code +splitting, check every output body for runtime imports and unresolved dynamic +execution/network primitives, and compare two builds of the same captured inputs. +Lexical binding matters: the admission scanner's local `require` function remains +unchanged and is not a Node loader. The output guard also follows Fable's const +global aliases, confines them to selected static mechanical/encoding members and +refuses global-object escape or dynamic property access. It is not a general +JavaScript sandbox. Package custody joins generated input hashes, +mechanical source, actual tool/lock identity and final bundle bytes. Raw generated +policy remains source provenance; the runtime always selects the closed entry. + +Optional bounded observations cover handler readiness, load receipt, strict +admission, hashing and native compilation/instantiation, plus actual transport +clock/timer/create/post/retirement calls. Worker-local and page clocks remain +separate. Advisory packets are filtered before lifecycle delivery; unavailable, +excluded, truncated and read-failed observations remain explicit. The original +pre-create deadline, identity, capacity and termination policies are unchanged. +A retired physical Worker's error also cannot borrow a newly created same-name +Worker's command identity. + +Prepared installed controls exercise a fresh initial Worker, candidate abort and +new physical candidate, real cold expiry, and a genuine loaded message withheld +until expiry then released as late evidence. The latter is an explicit delivery +impairment control, not evidence of a naturally late callback. Existing normal +configuration, ABI/refusal/profile and lifecycle controls remain. No priming, +retry, clock enlargement, speculative HTTP cache change or consumer asset rewrite +is selected. The existing 35-minute hosted producer profile remains the bound; +new tool/build/browser fit is unqualified until its original complete run. + +The first candidate qualifier +[39069862111](https://github.com/FS-GG/FS.GG.Game/actions/runs/39069862111), attempt 1, +failed static preflight on the Lifecycle.Tests project dependency range before +compiler or runtime qualification. The connected repair selects the current +Contracts project version in that lock edge and tests the previous published +version as a refusal. All other restored package hashes and historical package +locks remain unchanged; a fresh exact-head full qualifier is still required. + +Source preparation does not publish `0.5.0` or qualify installed adoption. Its +actual future publication P and read-only installed qualifier cannot be guessed; +the existing finite installed binder still identifies authentic published `0.4.0` +until a genuine successor publication supplies the next closed binding. Consumer +adoption and native/product outcomes remain separately owned. This rationale +comes from the producer's own source; private consumer evidence is not part of +the public producer record. + ## Current 0.4 producer supplier acceptance Published producer and original publisher P remains diff --git a/eng/wasm-shared/version.props b/eng/wasm-shared/version.props index 4b6a06ac..040cac38 100644 --- a/eng/wasm-shared/version.props +++ b/eng/wasm-shared/version.props @@ -1,6 +1,6 @@ - 0.4.0 + 0.5.0 wasm/v$(WasmSharedVersion) diff --git a/examples/wasm/rust/bar-guest/Cargo.lock b/examples/wasm/rust/bar-guest/Cargo.lock index 0bf0d626..7e2afa94 100644 --- a/examples/wasm/rust/bar-guest/Cargo.lock +++ b/examples/wasm/rust/bar-guest/Cargo.lock @@ -3,11 +3,11 @@ version = 4 [[package]] name = "fsgg-wasm-bar-example" -version = "0.4.0" +version = "0.5.0" dependencies = [ "fsgg-wasm-guest", ] [[package]] name = "fsgg-wasm-guest" -version = "0.4.0" +version = "0.5.0" diff --git a/examples/wasm/rust/bar-guest/Cargo.toml b/examples/wasm/rust/bar-guest/Cargo.toml index a64014c8..bfa8ef31 100644 --- a/examples/wasm/rust/bar-guest/Cargo.toml +++ b/examples/wasm/rust/bar-guest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fsgg-wasm-bar-example" -version = "0.4.0" +version = "0.5.0" edition = "2021" license = "MIT" publish = false diff --git a/examples/wasm/rust/sc2-guest/Cargo.lock b/examples/wasm/rust/sc2-guest/Cargo.lock index a7bf6260..3bdb48ab 100644 --- a/examples/wasm/rust/sc2-guest/Cargo.lock +++ b/examples/wasm/rust/sc2-guest/Cargo.lock @@ -3,11 +3,11 @@ version = 4 [[package]] name = "fsgg-wasm-sc2-example" -version = "0.4.0" +version = "0.5.0" dependencies = [ "fsgg-wasm-guest", ] [[package]] name = "fsgg-wasm-guest" -version = "0.4.0" +version = "0.5.0" diff --git a/examples/wasm/rust/sc2-guest/Cargo.toml b/examples/wasm/rust/sc2-guest/Cargo.toml index 007917fc..9316395c 100644 --- a/examples/wasm/rust/sc2-guest/Cargo.toml +++ b/examples/wasm/rust/sc2-guest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fsgg-wasm-sc2-example" -version = "0.4.0" +version = "0.5.0" edition = "2021" license = "MIT" publish = false diff --git a/scripts/verify-wasm-package-consumer.sh b/scripts/verify-wasm-package-consumer.sh index 9a68818a..b0f83cef 100755 --- a/scripts/verify-wasm-package-consumer.sh +++ b/scripts/verify-wasm-package-consumer.sh @@ -135,7 +135,7 @@ if grep -Fq "$repo/src/" "$work/consumer/obj/project.assets.json"; then fi NUGET_PACKAGES="$work/consumer-packages" dotnet build "$work/consumer/Consumer.fsproj" -c Release --no-restore --nologo output="$work/consumer/public/sub/app" -for asset in module-worker.mjs worker-client.mjs policy/WorkerEntry.js policy/Invocation.js policy/Admission.js; do +for asset in module-worker.mjs worker-client.mjs worker-build.json policy/WorkerEntry.js policy/Invocation.js policy/Admission.js; do test -f "$output/_content/FS.GG.Wasm.Browser/$asset" done NUGET_PACKAGES="$work/consumer-packages" dotnet tool run fable "$work/consumer/Consumer.fsproj" --outDir "$output/fable" --noCache diff --git a/scripts/verify-wasm-workflow.sh b/scripts/verify-wasm-workflow.sh index 2602c2ba..20f54c36 100755 --- a/scripts/verify-wasm-workflow.sh +++ b/scripts/verify-wasm-workflow.sh @@ -384,7 +384,7 @@ def validate(version,sdk,consumer,contracts,lifecycle,lock): sdk=(repo/'sdk/wasm/VERSION').read_text().strip();consumer=(repo/'tests/Wasm.PackageConsumer/Consumer.fsproj').read_text();contracts=(repo/'scripts/verify-wasm-contracts.sh').read_text();lifecycle=(repo/'scripts/verify-wasm-lifecycle.sh').read_text() lock=json.loads((repo/'tests/Wasm.Lifecycle.Tests/packages.lock.json').read_text()) validate(version,sdk,consumer,contracts,lifecycle,lock) -stale=json.loads(json.dumps(lock));stale['dependencies']['net10.0']['fs.gg.wasm.browser']['dependencies']['FS.GG.Wasm.Contracts']='[0.3.0, )' +stale=json.loads(json.dumps(lock));stale['dependencies']['net10.0']['fs.gg.wasm.browser']['dependencies']['FS.GG.Wasm.Contracts']='['+policy['baselineVersion']+', )' for broken in [(version,'0.2.0',consumer,contracts,lifecycle,lock),(version,sdk,consumer,contracts.replace('$version.nupkg','0.2.0.nupkg'),lifecycle,lock),(version,sdk,consumer.replace('Version="[$(WasmCandidateVersion)]"','Version="[0.2.0]"'),contracts,lifecycle,lock),(version,sdk,consumer.replace('Name="RequireCandidateVersion"','Name="MissingGuard"'),contracts,lifecycle,lock),(version,sdk,consumer,contracts,lifecycle,stale)]: try:validate(*broken) except ValueError:pass @@ -404,13 +404,13 @@ assert text.count('-p:WasmCandidateVersion="$version"')==3 with tempfile.TemporaryDirectory() as root: root=pathlib.Path(root);assembly=root/'current.dll';log=root/'build.log' assembly.write_bytes(b'owned inert assembly fixture') - header='wasm-supervisor-build phase=source source=fixture-head tree=fixture-tree version=0.4.0' + header='wasm-supervisor-build phase=source source=fixture-head tree=fixture-tree version=0.5.0' good=header+'\nwasm-supervisor-assembly sha256='+hashlib.sha256(assembly.read_bytes()).hexdigest()+'\n'+header+' status=success\n' def run(body,phase='source',binary=None): log.write_text(body) - return subprocess.run(['bash','-c','set -euo pipefail\nsource_head=fixture-head;source_tree=fixture-tree;version=0.4.0\n'+helpers+'\nvalidate_build "$1" "$2" "$3"\nprintf admitted', 'phase-control',str(log),phase,str(binary or assembly)],capture_output=True,text=True) + return subprocess.run(['bash','-c','set -euo pipefail\nsource_head=fixture-head;source_tree=fixture-tree;version=0.5.0\n'+helpers+'\nvalidate_build "$1" "$2" "$3"\nprintf admitted', 'phase-control',str(log),phase,str(binary or assembly)],capture_output=True,text=True) result=run(good);assert result.returncode==0 and result.stdout=='admitted',result.stderr - for bad in ['',good.replace('fixture-head','stale-head'),good.replace('status=success','status=partial'),good.replace('version=0.4.0','version=0.3.0'),good.replace('sha256=','sha256=00')]: + for bad in ['',good.replace('fixture-head','stale-head'),good.replace('status=success','status=partial'),good.replace('version=0.5.0','version=0.3.0'),good.replace('sha256=','sha256=00')]: result=run(bad);assert result.returncode!=0 and 'admitted' not in result.stdout result=run(good,'installed');assert result.returncode!=0 and 'admitted' not in result.stdout link=root/'linked.dll';link.symlink_to(assembly) diff --git a/scripts/wasm-release/api-baseline-policy-0.4.0.json b/scripts/wasm-release/api-baseline-policy-0.4.0.json new file mode 100644 index 00000000..716f91c4 --- /dev/null +++ b/scripts/wasm-release/api-baseline-policy-0.4.0.json @@ -0,0 +1,20 @@ +{ + "baselineVersion": "0.3.0", + "baselineTag": "wasm/v0.3.0", + "baselineSource": "16a401692f4c0dee7f6da1a86d0cd49e6ea3ec2c", + "baselineTree": "f06c9fef54b90d9dcd190b5bc1484f2e2e72f67a", + "baselineManifestSha256": "a10046daf5417304732605745608b084f14481212931cb69939d51f90233ed07", + "candidateVersion": "0.4.0", + "packages": { + "FS.GG.Wasm.Contracts": { + "servedArchiveSha256": "90839f0bef2688447a55568dcb370941efec6f4e043742b9b4b8969c247acfa1", + "expectedStatus": "compatible", + "removedConstructorTypes": [] + }, + "FS.GG.Wasm.Browser": { + "servedArchiveSha256": "74a11425bfb64c152ffbeab5be2753434e08f55f806e85113cdc25cacbc86fa5", + "expectedStatus": "compatible", + "removedConstructorTypes": [] + } + } +} diff --git a/scripts/wasm-release/api-baseline-policy.json b/scripts/wasm-release/api-baseline-policy.json index 716f91c4..8258c5d1 100644 --- a/scripts/wasm-release/api-baseline-policy.json +++ b/scripts/wasm-release/api-baseline-policy.json @@ -1,18 +1,18 @@ { - "baselineVersion": "0.3.0", - "baselineTag": "wasm/v0.3.0", - "baselineSource": "16a401692f4c0dee7f6da1a86d0cd49e6ea3ec2c", - "baselineTree": "f06c9fef54b90d9dcd190b5bc1484f2e2e72f67a", - "baselineManifestSha256": "a10046daf5417304732605745608b084f14481212931cb69939d51f90233ed07", - "candidateVersion": "0.4.0", + "baselineVersion": "0.4.0", + "baselineTag": "wasm/v0.4.0", + "baselineSource": "6732d2371d142d5ccc5d2d127f20e4f179800892", + "baselineTree": "cc71e7cfe73449919376acf775940ff9e8bdab56", + "baselineManifestSha256": "f8f47c2f5ae6fd8b5aef474dfc83b096aa0a6ad0265eb16dc4f0df7ade7f01b7", + "candidateVersion": "0.5.0", "packages": { "FS.GG.Wasm.Contracts": { - "servedArchiveSha256": "90839f0bef2688447a55568dcb370941efec6f4e043742b9b4b8969c247acfa1", + "servedArchiveSha256": "2c3ed82724fc41bb86141369d297b393e847a172475709254456e9ffd42a6992", "expectedStatus": "compatible", "removedConstructorTypes": [] }, "FS.GG.Wasm.Browser": { - "servedArchiveSha256": "74a11425bfb64c152ffbeab5be2753434e08f55f806e85113cdc25cacbc86fa5", + "servedArchiveSha256": "8231d666b5001d334b1591c71795c800b87e0bb58336b55826ec6486f787de69", "expectedStatus": "compatible", "removedConstructorTypes": [] } diff --git a/scripts/wasm-release/build-worker-policy.sh b/scripts/wasm-release/build-worker-policy.sh index 6bc32781..ba695210 100755 --- a/scripts/wasm-release/build-worker-policy.sh +++ b/scripts/wasm-release/build-worker-policy.sh @@ -4,8 +4,22 @@ repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" output="$(realpath -m "${1:?usage: build-worker-policy.sh }")" case "$output/" in "$repo/"*) echo 'generated Worker policy requires a root outside source' >&2; exit 2 ;; esac [[ ! -e "$output" ]] || { echo 'generated Worker policy root already exists' >&2; exit 2; } +node --test "$repo/tests/release/wasm/test-worker-transport.mjs" dotnet tool run fable "$repo/src/Wasm.Browser/FS.GG.Wasm.Browser.fsproj" --outDir "$output" --noCache [[ -f "$output/WorkerEntry.js" ]] # The native module Worker imports this generated, source-authoritative policy. node --check "$output/WorkerEntry.js" python3 "$repo/tests/release/wasm/test-worker-policy-closure.py" --generated "$output" +# This build-only tool has exact dependency/integrity locks. No dependency +# install scripts run; its platform binary is authenticated by the lock and +# recorded in the generated build receipt, never shipped as a runtime tool. +tool_root="$(mktemp -d "${output}.build-tool.XXXXXX")" +trap 'rm -rf "$tool_root"' EXIT +# Dependency installation never dirties source or enters the raw policy glob. +for leaf in package.json package-lock.json build.mjs closed-module.mjs closed-module.test.mjs; do + cp "$repo/scripts/wasm-release/worker-bundle/$leaf" "$tool_root/$leaf" +done +npm ci --prefix "$tool_root" --ignore-scripts --include=dev --include=optional --no-audit --no-fund +node --test "$tool_root/closed-module.test.mjs" +node "$tool_root/build.mjs" "$output" "$repo" +node --check "$output/module-worker.mjs" diff --git a/scripts/wasm-release/installed-evidence.py b/scripts/wasm-release/installed-evidence.py index dc7c5a65..8b0dad4d 100644 --- a/scripts/wasm-release/installed-evidence.py +++ b/scripts/wasm-release/installed-evidence.py @@ -50,10 +50,10 @@ def fable_dependency(name): return ('library-runtime', 'fs.gg.wasm.browser', 'contentFiles/any/any/_content/FS.GG.Wasm.Browser/policy/fable_modules/'+relative) modules = { - 'FS.GG.Wasm.Browser.0.4.0': ('fs.gg.wasm.browser', + 'FS.GG.Wasm.Browser.0.5.0': ('fs.gg.wasm.browser', ('Admission', 'Host', 'Invocation', 'Lifecycle', 'RuntimeProtocol', 'WorkerEntry'), 'FS.GG.Wasm.Browser'), - 'FS.GG.Wasm.Contracts.0.4.0': ('fs.gg.wasm.contracts', ('Contracts',), 'FS.GG.Wasm.Contracts')} + 'FS.GG.Wasm.Contracts.0.5.0': ('fs.gg.wasm.contracts', ('Contracts',), 'FS.GG.Wasm.Contracts')} if module not in modules: raise ValueError('unreviewed Fable dependency module') package, stems, project = modules[module] if leaf in {stem+suffix for stem in stems for suffix in ('.fs', '.fsi')}: @@ -62,7 +62,7 @@ def fable_dependency(name): return ('compiled-package-source', package, 'fable/'+leaf[:-3]) if leaf == project+'.fableproj': return ('derived-project', package, 'fable/'+project+'.fsproj') - if package == 'fs.gg.wasm.browser' and leaf in ('eng/wasm-shared/version.props', 'js/wasm-primitives.js'): + if package == 'fs.gg.wasm.browser' and leaf in ('eng/wasm-shared/version.props', 'js/wasm-primitives.js', 'js/module-worker.entry.mjs'): return ('package-source', package, 'fable/'+leaf) raise ValueError('unreviewed Fable package dependency member') @@ -70,10 +70,10 @@ def fable_dependency(name): def consumer_member(name): safe_name(name) fixed={'consumer/release-manifest.json','consumer/NuGet.Config','consumer/consumer/packages.lock.json', - 'consumer/fsgg-wasm-sdk-0.4.0.tar.gz','consumer/installed-member-receipt.log','consumer/browser-result.json','consumer/http.log','consumer/public/sub/app/Consumer'} + 'consumer/fsgg-wasm-sdk-0.5.0.tar.gz','consumer/installed-member-receipt.log','consumer/browser-result.json','consumer/http.log','consumer/public/sub/app/Consumer'} if name in fixed:return True for ident in PACKAGES: - if name in {'consumer/packages/'+ident+'/'+leaf for leaf in (ident+'.0.4.0.nupkg',ident+'.0.4.0.nupkg.sha512','.nupkg.metadata')}:return True + if name in {'consumer/packages/'+ident+'/'+leaf for leaf in (ident+'.0.5.0.nupkg',ident+'.0.5.0.nupkg.sha512','.nupkg.metadata')}:return True if name in {'consumer/core/'+leaf for leaf in ('fsharp.core.10.1.302.nupkg','fsharp.core.10.1.302.nupkg.sha512','.nupkg.metadata','reference-netstandard2.0.dll','reference-netstandard2.1.dll')}:return True if '/fable_modules/' in name: context = name.split('/fable_modules/', 1)[0] @@ -130,7 +130,7 @@ def dependency(self, name): raise ValueError('pinned Fable library metadata drift') return context = 'consumer' if name.startswith('consumer/') else 'selected' - original = self.files[context+'/packages/'+package+'/'+package+'.0.4.0.nupkg'] + original = self.files[context+'/packages/'+package+'/'+package+'.0.5.0.nupkg'] with zipfile.ZipFile(io.BytesIO(original)) as archive: rows = [row for row in archive.infolist() if row.filename == member] if len(rows) != 1 or rows[0].is_dir() or rows[0].file_size > LIMITS['file']: @@ -174,8 +174,8 @@ def tree(self, path, prefix): self.errors.append({'member':prefix,'status':'refused','reason':str(error)}) def packages(self, cache, prefix): for ident in PACKAGES: - base=pathlib.Path(cache)/ident/'0.4.0' - for leaf in (ident+'.0.4.0.nupkg',ident+'.0.4.0.nupkg.sha512','.nupkg.metadata'): + base=pathlib.Path(cache)/ident/'0.5.0' + for leaf in (ident+'.0.5.0.nupkg',ident+'.0.5.0.nupkg.sha512','.nupkg.metadata'): self.add(base/leaf,prefix+'/'+ident+'/'+leaf) def core(self, cache, prefix): base=pathlib.Path(cache)/'fsharp.core/10.1.302' @@ -228,7 +228,7 @@ def consumer(projection, work): work=pathlib.Path(work) for name in ('release-manifest.json','NuGet.Config','consumer/packages.lock.json'): projection.add(work/name,'consumer/'+name) - projection.add(work/'sdk-artifacts/fsgg-wasm-sdk-0.4.0.tar.gz','consumer/fsgg-wasm-sdk-0.4.0.tar.gz') + projection.add(work/'sdk-artifacts/fsgg-wasm-sdk-0.5.0.tar.gz','consumer/fsgg-wasm-sdk-0.5.0.tar.gz') projection.add(work/'installed-member-receipt.log','consumer/installed-member-receipt.log') projection.add(work/'consumer-http.log','consumer/http.log') projection.packages(work/'consumer-packages','consumer/packages') @@ -239,6 +239,7 @@ def consumer(projection, work): 'FSharp.Core.dll','Consumer.dll','fable/Program.js', '_content/FS.GG.Wasm.Browser/module-worker.mjs', '_content/FS.GG.Wasm.Browser/worker-client.mjs', + '_content/FS.GG.Wasm.Browser/worker-build.json', '_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js')]) # Consumer.fsproj intentionally places the actual DLL/Core/runtime outputs # in consumer/public/sub/app, already included above. @@ -268,6 +269,7 @@ def selected(projection, work): 'fable/Program.js','fable/BrowserBridge.js','limits-guest.wasm','http.log', '_content/FS.GG.Wasm.Browser/module-worker.mjs', '_content/FS.GG.Wasm.Browser/worker-client.mjs', + '_content/FS.GG.Wasm.Browser/worker-build.json', '_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js')]) projection.require(['selected/installed-fable/Program.js','selected/installed-fable/BrowserBridge.js']) runtime=ROOT/'tests/Wasm.Supervisor.Compatibility/Installed/bin/Debug/net10.0' @@ -322,11 +324,11 @@ def main(): if args.action=='consumer': consumer(p,args.work) else: merge(p,args.consumer); selected(p,args.work) - for name in ('release-manifest.json','promotion-binding.json','installed-input-audit.json','published-native.json','fsgg-wasm-sdk-0.4.0.tar.gz'): + for name in ('release-manifest.json','promotion-binding.json','installed-input-audit.json','published-native.json','fsgg-wasm-sdk-0.5.0.tar.gz'): p.add(pathlib.Path(args.receipts)/name,'receipts/'+name) for name in ('consumer.log','selected.log','runtime-status.json','consumer-status.json'): p.add(pathlib.Path(args.logs)/name,'execution/'+name) - for consumer_name,receipt_name in [('consumer/release-manifest.json','receipts/release-manifest.json'),('consumer/fsgg-wasm-sdk-0.4.0.tar.gz','receipts/fsgg-wasm-sdk-0.4.0.tar.gz')]: + for consumer_name,receipt_name in [('consumer/release-manifest.json','receipts/release-manifest.json'),('consumer/fsgg-wasm-sdk-0.5.0.tar.gz','receipts/fsgg-wasm-sdk-0.5.0.tar.gz')]: if consumer_name not in p.files or receipt_name not in p.files or p.files[consumer_name]!=p.files[receipt_name]: p.errors.append({'member':consumer_name,'status':'absent/refused','reason':'consumer original differs from authenticated publication receipt'}) try: diff --git a/scripts/wasm-release/promotion.py b/scripts/wasm-release/promotion.py index b944fd23..e27112fd 100644 --- a/scripts/wasm-release/promotion.py +++ b/scripts/wasm-release/promotion.py @@ -95,7 +95,7 @@ def manifest_tool(): def publication_identity(subject): need(type(subject) is PublicationSubject, 'explicit publication subject required') sha(subject.producer, 40); sha(subject.tree, 40) - need(subject.version == VERSION == '0.4.0', 'wrong published subject version') + need(subject.version == VERSION == '0.5.0', 'wrong published subject version') return subject.producer, subject.tree @@ -133,7 +133,7 @@ def validate_binding(binding, *, subject=None): 'workflowSha','job','event','evidence'} need(set(binding) == fields, 'unreviewed preparation fields') need(binding['schema'] == SCHEMA and binding['repository'] == REPO and binding['workflow'] == '.github/workflows/wasm-shared.yml' and binding['mode'] == 'qualify', 'unrelated preparation route') - need(VERSION == '0.4.0' and binding['producer'] == binding['executor'] == binding['workflowSha'] == producer and binding['producerTree'] == binding['executorTree'] == tree and binding['version'] == VERSION, 'wrong selected producer/executor/tree/version') + need(VERSION == '0.5.0' and binding['producer'] == binding['executor'] == binding['workflowSha'] == producer and binding['producerTree'] == binding['executorTree'] == tree and binding['version'] == VERSION, 'wrong selected producer/executor/tree/version') need(binding['job'] == 'qualify' and binding['event'] in ('push','workflow_dispatch'), 'premerge/generation-only custody refuses') need(binding['runAttempt'] == 1, 'rerun custody refuses') need(set(binding['evidence']) == OUTER_FILES - {'preparation-binding.json'} - set(FILES), 'wrong closed evidence roster') @@ -641,7 +641,7 @@ def custody_plan(): def freeze(custody,output): custody_plan() - need(VERSION=='0.4.0', 'only selected current release can freeze') + need(VERSION=='0.5.0', 'only selected current release can freeze') need(os.environ['GITHUB_REPOSITORY']==REPO and os.environ['GITHUB_REF']=='refs/heads/main' and os.environ['GITHUB_SHA']==os.environ['GITHUB_WORKFLOW_SHA']==P, 'premerge/source/workflow substitution') event=os.environ['GITHUB_EVENT_NAME'] need(event=='push' or (event=='workflow_dispatch' and os.environ.get('WASM_QUALIFY_EXPECTED_HEAD')==P), 'qualification dispatch needs selected protected head') diff --git a/scripts/wasm-release/qualify-supervisor-installed.sh b/scripts/wasm-release/qualify-supervisor-installed.sh index 1b44533c..9c13e705 100755 --- a/scripts/wasm-release/qualify-supervisor-installed.sh +++ b/scripts/wasm-release/qualify-supervisor-installed.sh @@ -12,7 +12,7 @@ feed="${WASM_PACKAGE_FEED:?}" receipts="${RUNNER_TEMP:?}/wasm-installed-receipts" [[ -f "$receipts/release-manifest.json" && -f "$receipts/promotion-binding.json" ]] || { echo 'requires genuine binder-downloaded immutable public receipts' >&2; exit 2; } version="$(sed -n 's:.*\([^<]*\).*:\1:p' "$repo/eng/wasm-shared/version.props")" -[[ "$version" == 0.4.0 && "${RELEASE_VERSION:?}" == "$version" ]] || { echo 'requires selected current installed version' >&2; exit 2; } +[[ "$version" == 0.5.0 && "${RELEASE_VERSION:?}" == "$version" ]] || { echo 'requires selected current installed version' >&2; exit 2; } q="${output}-baseline" "$repo/scripts/wasm-release/qualify-supervisor-custody.sh" --bootstrap "$q" config="${output}-NuGet.Config" diff --git a/scripts/wasm-release/release_manifest.py b/scripts/wasm-release/release_manifest.py index 39ae89aa..a42a9e8e 100755 --- a/scripts/wasm-release/release_manifest.py +++ b/scripts/wasm-release/release_manifest.py @@ -18,7 +18,7 @@ API_POLICY = json.loads((Path(__file__).parent / "api-baseline-policy.json").read_text()) # Explicit historical policies verify immutable evidence; they select no current execution. HISTORICAL_API_POLICIES = [json.loads((Path(__file__).parent / name).read_text()) for name in - ("api-baseline-policy-0.2.0.json", "api-baseline-policy-0.3.0.json")] + ("api-baseline-policy-0.2.0.json", "api-baseline-policy-0.3.0.json", "api-baseline-policy-0.4.0.json")] def api_policy(version): if version == API_POLICY["candidateVersion"]: @@ -82,7 +82,59 @@ def nuspec(package: Path) -> tuple[dict[str, str], dict[str, str], list[str]]: return values, dependencies, names -def inspect_package(path: Path, expected_id: str, version: str, source: str) -> dict[str, object]: +def verify_worker_build(archive: zipfile.ZipFile, source: str, tree: str) -> dict[str, object]: + prefix = "contentFiles/any/any/_content/FS.GG.Wasm.Browser/" + def read(name, limit=8*1024*1024): + info = archive.getinfo(name) + if info.file_size > limit: + fail("Worker build member exceeds its bound") + return archive.read(name) + try: + record = json.loads(read(prefix+"worker-build.json", 256*1024)) + if record["schema"] != "fsgg.wasm.worker-build/1" or record["sourceCommit"] != source or not re.fullmatch(r"[0-9a-f]{40}", tree) or record["sourceTree"] != tree: + fail("Worker build source binding differs") + expected = {"format":"esm","platform":"browser","target":"es2022","minify":False,"treeShaking":False, + "keepNames":True,"deterministicBuildsCompared":2,"outputFiles":1,"externalImports":0} + if record["composition"] != expected or record["syntaxParser"] != {"name":"acorn","version":"8.19.0"} or record["scopeAnalyzer"] != {"name":"eslint-scope","version":"9.1.2"} or record["fableVersion"] != "5.18.0": + fail("Worker build composition/tool selection differs") + def matches(row, name): + value = read(name) + if row["bytes"] != len(value) or row["sha256"] != hashlib.sha256(value).hexdigest(): + fail("Worker build member hash/length differs") + if record["output"]["path"] != "module-worker.mjs": + fail("Worker build entry differs") + matches(record["output"], prefix+"module-worker.mjs") + if record["mechanicalEntry"]["path"] != "src/Wasm.Browser/js/module-worker.mjs": + fail("Worker mechanical source path differs") + matches(record["mechanicalEntry"], "fable/js/module-worker.entry.mjs") + rows = record["generatedInputs"] + if not isinstance(rows, list) or not 5 <= len(rows) <= 256: + fail("Worker generated input population differs") + paths = [row["path"] for row in rows] + safe_members(paths, "Worker generated input") + if paths != sorted(paths) or not all(isinstance(name, str) and name.endswith(".js") and "\\" not in name and all(part not in ("", ".", "..") for part in name.split("/")) for name in paths): + fail("Worker generated inputs are not canonical") + if not {"WorkerEntry.js","Admission.js","Invocation.js","RuntimeProtocol.js"}.issubset(paths) or not any(name.endswith("/Contracts.js") for name in paths): + fail("Worker generated policy closure is incomplete") + if not all(type(row["bytes"]) is int and 0 < row["bytes"] <= 8*1024*1024 for row in rows) or sum(row["bytes"] for row in rows) > 32*1024*1024: + fail("Worker generated closure exceeds its bound") + for row in rows: + matches(row, prefix+"policy/"+row["path"]) + lock_bytes = read("build-provenance/worker-bundle/package-lock.json", 256*1024) + if lock_bytes != (Path(__file__).parent/"worker-bundle/package-lock.json").read_bytes(): + fail("Worker build lock differs from selected source") + tool = record["tool"] + if tool["name"] != "esbuild" or tool["version"] != "0.28.2" or not re.fullmatch(r"[0-9a-f]{64}", tool["binarySha256"]) or tool["lockSha256"] != hashlib.sha256(lock_bytes).hexdigest(): + fail("Worker build tool/lock identity differs") + lock = json.loads(lock_bytes) + if lock["packages"][""]["devDependencies"] != {"acorn":"8.19.0","esbuild":"0.28.2","eslint-scope":"9.1.2"}: + fail("Worker build dependency selection differs") + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + fail(f"invalid Worker build closure: {error}") + return {"bundleSha256":record["output"]["sha256"],"generatedInputs":len(rows),"externalImports":0} + + +def inspect_package(path: Path, expected_id: str, version: str, source: str, tree: str) -> dict[str, object]: values, dependencies, names = nuspec(path) if values != {"id": expected_id, "version": version, "repositoryCommit": source}: fail(f"{path.name} identity/source mismatch: {values}") @@ -123,6 +175,10 @@ def inspect_package(path: Path, expected_id: str, version: str, source: str) -> with zipfile.ZipFile(path) as archive: props = archive.read("fable/eng/wasm-shared/version.props").decode("utf-8-sig") project = archive.read("fable/FS.GG.Wasm.Browser.fsproj").decode("utf-8-sig") + # Historical immutable releases retain their original package + # layout; current producer preparation requires the closed entry. + if version not in ("0.1.1", "0.2.0", "0.3.0", "0.4.0"): + verify_worker_build(archive, source, tree) if f"{version}" not in props or 'Version="[$(WasmSharedVersion)]"' not in project: fail("packaged Fable dependency does not resolve through the stable version origin") return { @@ -188,7 +244,7 @@ def verify_manifest(path: Path, custody: Path, source: str | None = None) -> dic if not artifact.is_file() or digest(artifact) != row.get("sha256"): fail(f"missing or mismatched custody artifact: {artifact.name}") expected_rows = [ - inspect_package(custody / files[identity], identity, version, str(data["source"])) + inspect_package(custody / files[identity], identity, version, str(data["source"]), str(data["tree"])) for identity in ROSTER ] + [inspect_sdk(custody / files["sdk"], version)] if artifacts != expected_rows: @@ -267,7 +323,7 @@ def prepare(args: argparse.Namespace) -> None: fail("release version must be stable SemVer") custody = Path(args.custody) files = expected_files(version) - packages = [inspect_package(custody / files[identity], identity, version, args.source) for identity in ROSTER] + packages = [inspect_package(custody / files[identity], identity, version, args.source, args.tree) for identity in ROSTER] sdk = inspect_sdk(custody / files["sdk"], version) data = { "schema": SCHEMA, diff --git a/scripts/wasm-release/worker-bundle/build.mjs b/scripts/wasm-release/worker-bundle/build.mjs new file mode 100644 index 00000000..0bfafce0 --- /dev/null +++ b/scripts/wasm-release/worker-bundle/build.mjs @@ -0,0 +1,98 @@ +import { build, version } from "esbuild"; +import { readFile, writeFile, realpath, lstat } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { validateClosedModule } from "./closed-module.mjs"; + +const here=path.dirname(fileURLToPath(import.meta.url)),repo=await realpath(process.argv[3]??""); +const generated=await realpath(process.argv[2]??""); +if(generated===repo||generated.startsWith(repo+path.sep))throw new Error("Worker generation requires a private root outside source"); +if(version!=="0.28.2"||process.platform!=="linux"||process.arch!=="x64")throw new Error("unselected Worker build tool/profile"); +if(process.env.ESBUILD_BINARY_PATH)throw new Error("Worker build refuses a native tool override"); +const sha=value=>createHash("sha256").update(value).digest("hex"); +const regular=async (file,maximum=8*1024*1024)=>{ + const info=await lstat(file); + if(await realpath(file)!==file||!info.isFile()||info.size>maximum)throw new Error("linked/nonregular/oversized Worker build input"); + const bytes=await readFile(file); + if(bytes.length!==info.size||bytes.length>maximum)throw new Error("Worker input byte bound changed"); + return bytes; +}; +for(const leaf of ["package.json","package-lock.json","build.mjs","closed-module.mjs","closed-module.test.mjs"]) + if(!(await regular(path.join(here,leaf))).equals(await regular(path.join(repo,"scripts/wasm-release/worker-bundle",leaf)))) + throw new Error("isolated Worker tool differs from selected source"); +const entryPath=path.join(repo,"src/Wasm.Browser/js/module-worker.mjs"),entry=await regular(entryPath); +const entryInput=path.join(generated,"module-worker.entry.mjs"); +await writeFile(entryInput,entry,{flag:"wx"}); +// The native bundler receives these exact captured buffers on both builds. +// Hashing filenames only after compilation would not establish that join. +const snapshots=new Map();let capturedBytes=0; +const snapshot=file=>{ + if(!snapshots.has(file)){ + if(file!==entryInput&&(!file.startsWith(generated+path.sep)||!file.endsWith(".js")))throw new Error("Worker dependency escaped the selected generated tree"); + if(snapshots.size>=256)throw new Error("Worker input population bound exceeded"); + snapshots.set(file,(async()=>{ + const bytes=await regular(file);capturedBytes+=bytes.length; + if(capturedBytes>32*1024*1024)throw new Error("Worker input closure byte bound exceeded"); + new TextDecoder("utf-8",{fatal:true}).decode(bytes); + return bytes; + })()); + } + return snapshots.get(file); +}; +const options={absWorkingDir:generated,entryPoints:["module-worker.entry.mjs"], + outfile:"module-worker.mjs",bundle:true,format:"esm",platform:"browser",target:"es2022",splitting:false, + minify:false,treeShaking:false,ignoreAnnotations:true,keepNames:true,charset:"utf8",legalComments:"inline", + sourcemap:false,metafile:true,write:false,logLevel:"error", + logOverride:{"direct-eval":"error","unsupported-dynamic-import":"error"}, + plugins:[{name:"actual-generated-worker-policy",setup(api){ + api.onResolve({filter:/^\.\/policy\/WorkerEntry\.js$/},args=>{ + if(args.importer!==entryInput)return {errors:[{text:"unexpected mechanical policy import owner"}]}; + return {path:path.join(generated,"WorkerEntry.js")}; + }); + api.onLoad({filter:/.*/,namespace:"file"},async args=>({contents:await snapshot(args.path),loader:"js",resolveDir:path.dirname(args.path)})); + }}]}; +const first=await build(options),second=await build(options); +if(first.outputFiles.length!==1||second.outputFiles.length!==1 + ||first.outputFiles[0].path!==path.join(generated,"module-worker.mjs")||second.outputFiles[0].path!==first.outputFiles[0].path + ||!Buffer.from(first.outputFiles[0].contents).equals(Buffer.from(second.outputFiles[0].contents))) + throw new Error("Worker bundle is not a deterministic single artifact"); +if(JSON.stringify(Object.keys(first.metafile.inputs).sort())!==JSON.stringify(Object.keys(second.metafile.inputs).sort()) + ||Object.keys(first.metafile.inputs).length!==snapshots.size)throw new Error("Worker compilation input census differs"); +const output=Buffer.from(first.outputFiles[0].contents); +if(output.length>8*1024*1024)throw new Error("Worker artifact byte bound exceeded"); +const syntax=validateClosedModule(output.toString("utf8")); +if(Object.values(first.metafile.outputs).some(row=>row.imports.length))throw new Error("Worker bundle retains external imports"); +const inputs=[];let total=entry.length; +for(const name of Object.keys(first.metafile.inputs).sort()){ + if(name==="module-worker.entry.mjs")continue; + if(path.isAbsolute(name)||name.split(/[\\/]/).includes(".."))throw new Error("Worker input escaped generated root"); + const file=path.join(generated,name),bytes=await snapshots.get(file); + if(!bytes||!bytes.equals(await regular(file)))throw new Error("Worker input changed after captured compilation"); + if(first.metafile.inputs[name].bytes!==bytes.length||second.metafile.inputs[name].bytes!==bytes.length) + throw new Error("Native bundler input length differs from the captured bytes"); + total+=bytes.length; + if(total>32*1024*1024||inputs.length>=256)throw new Error("Worker input closure bound exceeded"); + inputs.push({path:name,bytes:bytes.length,sha256:sha(bytes)}); +} +for(const leaf of ["WorkerEntry.js","Admission.js","Invocation.js","RuntimeProtocol.js"]) + if(!inputs.some(row=>row.path===leaf))throw new Error("Worker bundle omitted authoritative policy input"); +if(!inputs.some(row=>row.path.endsWith("/Contracts.js")))throw new Error("Worker bundle omitted authoritative contracts"); +if(!entry.equals(await snapshots.get(entryInput))||!entry.equals(await regular(entryInput)))throw new Error("Worker mechanical input correspondence differs"); +const require=createRequire(import.meta.url),binary=require.resolve("@esbuild/linux-x64/bin/esbuild"); +const parser=JSON.parse(await readFile(require.resolve("acorn/package.json"),"utf8")); +const scopeAnalyzer=JSON.parse(await readFile(require.resolve("eslint-scope/package.json"),"utf8")); +if(parser.version!=="8.19.0"||scopeAnalyzer.version!=="9.1.2")throw new Error("unselected Worker syntax/scope tools"); +const git=(...args)=>execFileSync("git",["-C",repo,...args],{encoding:"utf8"}).trim(); +const manifest={schema:"fsgg.wasm.worker-build/1",sourceCommit:git("rev-parse","HEAD"),sourceTree:git("rev-parse","HEAD^{tree}"), + tool:{name:"esbuild",version,binarySha256:sha(await regular(binary,16*1024*1024)),lockSha256:sha(await regular(path.join(here,"package-lock.json")))}, + syntaxParser:{name:"acorn",version:parser.version},scopeAnalyzer:{name:"eslint-scope",version:scopeAnalyzer.version},fableVersion:"5.18.0", + mechanicalEntry:{path:"src/Wasm.Browser/js/module-worker.mjs",bytes:entry.length,sha256:sha(entry)},generatedInputs:inputs, + output:{path:"module-worker.mjs",bytes:output.length,sha256:sha(output)}, + composition:{format:"esm",platform:"browser",target:"es2022",minify:false,treeShaking:false,keepNames:true, + deterministicBuildsCompared:2,outputFiles:1,externalImports:syntax.externalImports}}; +await writeFile(path.join(generated,"module-worker.mjs"),output,{flag:"wx"}); +await writeFile(path.join(generated,"worker-build.json"),JSON.stringify(manifest,null,2)+"\n",{flag:"wx"}); +console.log(JSON.stringify({workerBundle:manifest.output,generatedInputs:inputs.length,tool:manifest.tool,externalImports:0})); diff --git a/scripts/wasm-release/worker-bundle/closed-module.mjs b/scripts/wasm-release/worker-bundle/closed-module.mjs new file mode 100644 index 00000000..3aa99ceb --- /dev/null +++ b/scripts/wasm-release/worker-bundle/closed-module.mjs @@ -0,0 +1,60 @@ +import { parse } from "acorn"; +import { analyze } from "eslint-scope"; + +// A syntax check of the actual delivered artifact, not an import-regex claim. +export function validateClosedModule(source) { + const ast=parse(source,{ecmaVersion:2022,sourceType:"module",ranges:true}); + const pending=[[ast,null]],parents=new Map();let nodes=0; + const forbidden=new Set(["eval","Function","require","fetch","importScripts","XMLHttpRequest","WebSocket"]); + while(pending.length){ + const [node,parent]=pending.pop();parents.set(node,parent); + if(++nodes>2_000_000)throw new Error("Worker syntax node bound exceeded"); + if(node.type==="ImportDeclaration"||node.type==="ImportExpression" + ||node.type==="ExportAllDeclaration"||node.type==="ExportNamedDeclaration"&&node.source) + throw new Error("Worker artifact has an external module dependency"); + for(const value of Object.values(node)){ + if(Array.isArray(value)){ + for(const child of value)if(child&&typeof child.type==="string")pending.push([child,node]); + }else if(value&&typeof value.type==="string")pending.push([value,node]); + } + } + // The generated admission scanner legitimately binds a local `require`. + // Resolve lexical references instead of treating its name as a Node import. + const scope=analyze(ast,{ecmaVersion:2022,sourceType:"module",impliedStrict:true}); + const free=new Set(scope.globalScope.through.map(reference=>reference.identifier)); + for(const reference of free)if(forbidden.has(reference.name)) + throw new Error("Worker artifact references a dynamic execution/network primitive"); + // The pinned Fable Encoding input uses const g=globalThis; g.Buffer. + // Track lexical const aliases, then confine EVERY use of a host-global root + // or alias. Merely checking direct globalThis.fetch would miss an escape. + const roots=new Map([...free].filter(node=>["globalThis","self","window"].includes(node.name)).map(node=>[node,node.name])); + const variables=scope.scopes.flatMap(value=>value.variables),references=new Map(); + for(const value of scope.scopes)for(const reference of value.references)references.set(reference.identifier,reference.resolved); + const aliases=new Map();let changed=true; + while(changed){ + changed=false; + for(const variable of variables){ + if(aliases.has(variable)||variable.defs.length!==1)continue; + const definition=variable.defs[0],declaration=definition.node; + if(definition.type!=="Variable"||declaration.id.type!=="Identifier"||parents.get(declaration)?.kind!=="const" + ||declaration.init?.type!=="Identifier")continue; + const root=roots.get(declaration.init)??aliases.get(references.get(declaration.init))?.root; + if(root){aliases.set(variable,{root,declaration});changed=true;} + } + } + const aliasDeclarations=new Set(); + for(const [variable,{root,declaration}] of aliases){ + aliasDeclarations.add(declaration); + for(const reference of variable.references)roots.set(reference.identifier,root); + } + const allowed={globalThis:new Set(["Buffer"]),self:new Set(["location","postMessage","onmessage"]),window:new Set()}; + for(const [node,root] of roots){ + const parent=parents.get(node); + if(parent?.type==="VariableDeclarator"&&aliasDeclarations.has(parent) + &&(parent.init===node||parent.id===node))continue; + const member=parent?.type==="MemberExpression"&&parent.object===node; + const property=member?(parent.computed&&parent.property.type==="Literal"?parent.property.value:!parent.computed?parent.property.name:null):null; + if(!member||!allowed[root].has(property))throw new Error("Worker host-global reference escapes its selected static members"); + } + return {nodes,externalImports:0}; +} diff --git a/scripts/wasm-release/worker-bundle/closed-module.test.mjs b/scripts/wasm-release/worker-bundle/closed-module.test.mjs new file mode 100644 index 00000000..16f7c478 --- /dev/null +++ b/scripts/wasm-release/worker-bundle/closed-module.test.mjs @@ -0,0 +1,40 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { validateClosedModule } from "./closed-module.mjs"; + +test("closed module accepts ordinary ESM native mechanics without dependencies",()=>{ + assert.equal(validateClosedModule("const memory = new Uint8Array(8); export { memory }; self.onmessage = () => memory.fill(0);").externalImports,0); +}); +test("authoritative admission's locally bound require is not a runtime import",()=>{ + assert.equal(validateClosedModule("function inspect(bytes) { const require = count => count <= bytes.length; return require(1); }").externalImports,0); +}); +test("pinned Fable Encoding const global alias retains only its selected Buffer member",()=>{ + assert.equal(validateClosedModule('function encode(x) { const g=globalThis; const h=g; return typeof h.Buffer !== "undefined" ? h.Buffer.from(x) : new Uint8Array(); }').externalImports,0); +}); +for(const source of [ + 'import { x } from "./missing.js";', + 'export * from "./missing.js";', + 'export { x } from "./missing.js";', + 'async function nested() { return import("./missing.js"); }', + 'function nested() { return eval("1"); }', + 'function nested() { return (0, eval)("1"); }', + 'function nested() { return globalThis.eval("1"); }', + 'function nested() { return new Function("return 1"); }', + 'function nested() { return Function`return 1`; }', + 'function nested() { return require("missing"); }', + 'function local() { const require = x => x; return require(1); } function other() { return require("missing"); }', + 'const F = Function; new F("return 1");', + 'const E = globalThis["eval"]; E("1");', + 'self.onmessage = () => fetch("https://example.invalid");', + 'self.onmessage = () => importScripts("elsewhere.js");', + 'self.onmessage = () => new WebSocket("wss://example.invalid");', + 'self.onmessage = () => new XMLHttpRequest();' + ,'const g=globalThis; g.fetch("https://example.invalid");' + ,'const g=globalThis; const h=g; h.eval("1");' + ,'const {fetch:f}=globalThis; f("https://example.invalid");' + ,'const g=globalThis; send(g);' + ,'const g=globalThis; const property="fetch"; g[property]("https://example.invalid");' + ,'let g=globalThis; g=other;' + ,'const g=self; g.importScripts("elsewhere.js");' + ,'const g=globalThis.self; g.fetch("https://example.invalid");' +])test(`refuses runtime dependency/execution: ${source}`,()=>assert.throws(()=>validateClosedModule(source))); diff --git a/scripts/wasm-release/worker-bundle/package-lock.json b/scripts/wasm-release/worker-bundle/package-lock.json new file mode 100644 index 00000000..6e4e460b --- /dev/null +++ b/scripts/wasm-release/worker-bundle/package-lock.json @@ -0,0 +1,570 @@ +{ + "name": "fsgg-wasm-worker-build", + "version": "0.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fsgg-wasm-worker-build", + "version": "0.5.0", + "devDependencies": { + "acorn": "8.19.0", + "esbuild": "0.28.2", + "eslint-scope": "9.1.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + } + } +} diff --git a/scripts/wasm-release/worker-bundle/package.json b/scripts/wasm-release/worker-bundle/package.json new file mode 100644 index 00000000..92fc904d --- /dev/null +++ b/scripts/wasm-release/worker-bundle/package.json @@ -0,0 +1,11 @@ +{ + "name": "fsgg-wasm-worker-build", + "private": true, + "type": "module", + "version": "0.5.0", + "devDependencies": { + "acorn": "8.19.0", + "esbuild": "0.28.2", + "eslint-scope": "9.1.2" + } +} diff --git a/sdk/wasm/VERSION b/sdk/wasm/VERSION index 1d0ba9ea..8f0916f7 100644 --- a/sdk/wasm/VERSION +++ b/sdk/wasm/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 diff --git a/sdk/wasm/rust/Cargo.lock b/sdk/wasm/rust/Cargo.lock index 9fd39b40..82dd79f0 100644 --- a/sdk/wasm/rust/Cargo.lock +++ b/sdk/wasm/rust/Cargo.lock @@ -3,4 +3,4 @@ version = 4 [[package]] name = "fsgg-wasm-guest" -version = "0.4.0" +version = "0.5.0" diff --git a/sdk/wasm/rust/fsgg-wasm-guest/Cargo.toml b/sdk/wasm/rust/fsgg-wasm-guest/Cargo.toml index 69a22141..7c5f17f2 100644 --- a/sdk/wasm/rust/fsgg-wasm-guest/Cargo.toml +++ b/sdk/wasm/rust/fsgg-wasm-guest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fsgg-wasm-guest" -version = "0.4.0" +version = "0.5.0" edition = "2021" license = "MIT" publish = false diff --git a/src/Wasm.Browser/FS.GG.Wasm.Browser.fsproj b/src/Wasm.Browser/FS.GG.Wasm.Browser.fsproj index 1aba1515..e12ac952 100644 --- a/src/Wasm.Browser/FS.GG.Wasm.Browser.fsproj +++ b/src/Wasm.Browser/FS.GG.Wasm.Browser.fsproj @@ -46,6 +46,11 @@ fable/js/wasm-primitives.js None + <_PackageFiles Include="js/module-worker.mjs"> + true + fable/js/module-worker.entry.mjs + None + <_PackageFiles Include="Fable/FS.GG.Wasm.Browser.fsproj"> true fable/FS.GG.Wasm.Browser.fsproj @@ -68,16 +73,20 @@ - + + + + - + diff --git a/src/Wasm.Browser/README.md b/src/Wasm.Browser/README.md index 365f60dc..ec37240b 100644 --- a/src/Wasm.Browser/README.md +++ b/src/Wasm.Browser/README.md @@ -1,11 +1,30 @@ # FS.GG.Wasm.Browser -Unpublished stable shared browser WASM runtime candidate for FS.GG.Game. Version -`0.1.1` contains the typed runtime plus package-owned module Worker assets. +The selected `0.5.0` source candidate prepares a closed module Worker from the +generated F# policy. Published `0.4.0` and its accepted installed evidence retain +their original identities; the new candidate still requires qualification and +coherent publication. The build-transitive package boundary copies those assets under `_content/FS.GG.Wasm.Browser/`; `worker-client.mjs` resolves its sibling Worker from the deployed asset base, including non-root applications. F# retains admission, lifecycle and settlement policy; the JavaScript assets perform browser mechanics. +The selected Worker is one bundled ESM artifact with no runtime imports. Raw +`policy/` files and the mechanical source remain package build provenance. +`worker-build.json` joins the generated input bytes, pinned build tools, source +commit/tree and actual bundle hash. The fixed npm build lock is included outside +the runtime asset tree. -This candidate is outside the Game solution and release set. It is unpublished and -has not been adopted by a product. +`createHostTransport(assetBase, { observe, include })` optionally reports bounded +scalar observations. `include` selects the observation scope; excluded events are +counted explicitly. Callbacks must be passive and bounded. Exceptions mark a read +gap while ordinary native effects continue. `DiagnosticStatus()` reports disabled, +unavailable, observed, truncated or read-failed health; it grants no readiness, +authority, complete coverage or successful cleanup. Worker-local elapsed values +and page clock values are separate clocks. Diagnostics never enter the lifecycle +reducer or renew its pre-create deadline. + +The normal transport has diagnostics disabled. Observed mode retains the same +request limits and adds real observation overhead. The build uses exact locked +[esbuild](https://esbuild.github.io/api/#bundle), Acorn and ESLint scope tools; +none is a consumer runtime dependency. Current compilation, fresh installed +Worker behavior and budget fit remain required before acceptance. diff --git a/src/Wasm.Browser/WorkerEntry.fs b/src/Wasm.Browser/WorkerEntry.fs index eda8bace..0b010196 100644 --- a/src/Wasm.Browser/WorkerEntry.fs +++ b/src/Wasm.Browser/WorkerEntry.fs @@ -341,6 +341,31 @@ module WorkerEntry = let mutable loaded: (ValidatedConfiguration * HostIdentity) option = None let mutable busy = false + // Internal advisory envelopes are consumed by the mechanical Worker + // adapter, never by the lifecycle reducer. Observer failure cannot + // replace admission, ABI or cleanup outcomes. + let diagnostic (identity: HostIdentity) correlation stage = + let value: WorkerWireObservation = + { + Identity = Identity.toWire identity + Correlation = OperationToken.value correlation + Kind = "diagnostic" + Phase = stage + Digest = "" + Version = 0u + State = "" + Status = 0 + Diagnostic = "" + CleanupDiagnostic = "" + Dispatched = false + Output = Array.empty + } + + try + emit value + with _ -> + () + let perform configuration effect = async { let abi = (Validation.descriptor configuration).Abi @@ -494,16 +519,20 @@ module WorkerEntry = try if wire.Kind = "load" then observe (PhaseObserved(identity, correlation, Compile)) - - match Admission.inspect configuration wire.Bytes with + diagnostic identity correlation "admission-begin" + let admission = Admission.inspect configuration wire.Bytes + diagnostic identity correlation "admission-end" + match admission with | Error issues -> observe ( WorkerFailed(identity, Some correlation, $"admission refused: {issues}") ) | Ok _ -> + diagnostic identity correlation "sha256-begin" let! digest = Async.FromContinuations(fun (ok, error, _) -> native.Sha256 wire.Bytes ok (System.Exception >> error)) + diagnostic identity correlation "sha256-end" if digest <> (Validation.configuration configuration).ArtifactSha256 then observe ( @@ -513,7 +542,7 @@ module WorkerEntry = observe (ArtifactDigestObserved(identity, correlation, digest)) observe (PhaseObserved(identity, correlation, Instantiate)) let abi = (Validation.descriptor configuration).Abi - + diagnostic identity correlation "instantiate-begin" let! version = Async.FromContinuations(fun (ok, error, _) -> native.Compile @@ -522,6 +551,7 @@ module WorkerEntry = abi.VersionExport ok (System.Exception >> error)) + diagnostic identity correlation "instantiate-end" if version <> abi.Version then observe ( diff --git a/src/Wasm.Browser/WorkerEntry.fsi b/src/Wasm.Browser/WorkerEntry.fsi index 6ddc07f6..558e98fd 100644 --- a/src/Wasm.Browser/WorkerEntry.fsi +++ b/src/Wasm.Browser/WorkerEntry.fsi @@ -59,4 +59,6 @@ module WorkerEntry = val configurationToWire: ValidatedConfiguration -> CandidateConfigurationBoundary val commandToWire: ValidatedConfiguration -> WorkerCommand -> WorkerWireCommand val observationFromWire: WorkerWireObservation -> Result + /// The mechanical adapter consumes internal diagnostic envelopes separately; + /// they are advisory and must never enter a lifecycle reducer. val createWireRuntime: WorkerWireMechanics -> (WorkerWireObservation -> unit) -> (WorkerWireCommand -> unit) diff --git a/src/Wasm.Browser/js/module-worker.mjs b/src/Wasm.Browser/js/module-worker.mjs index fdfb4d3c..794984ff 100644 --- a/src/Wasm.Browser/js/module-worker.mjs +++ b/src/Wasm.Browser/js/module-worker.mjs @@ -1,6 +1,41 @@ // WebAssembly and memory mechanics only. All admission, ownership, limits, // invocation order and cleanup decisions execute in the packaged F# policy. import { WorkerEntry_createWireRuntime } from "./policy/WorkerEntry.js"; +// Diagnostic messages are advisory, scalar-only and independent of lifecycle +// observations. Worker-local offsets never claim a join to the page clock. +const diagnosticsEnabled=self.location.hash==="#fsgg-worker-diagnostics-v1"; +const diagnosticStart=performance.now(); +const diagnosticStages=new Set(["handler-ready","load-received","admission-begin","admission-end", + "sha256-begin","sha256-end","instantiate-begin","instantiate-end","load-completed","terminal","error"]); +let diagnosticEvents=0,diagnosticBytes=0,diagnosticStopped=false; +const diagnosticStatus=status=>{ + if(!diagnosticsEnabled||diagnosticStopped)return; + diagnosticStopped=true; + try{self.postMessage({kind:"fsgg-worker-diagnostic/1",stage:"status",status, + elapsedMilliseconds:performance.now()-diagnosticStart,events:diagnosticEvents,bytes:diagnosticBytes});}catch{} +}; +const diagnostic=(stage,wire)=>{ + if(!diagnosticsEnabled||diagnosticStopped)return; + try{ + if(!diagnosticStages.has(stage))throw new Error("unknown diagnostic stage"); + let identity=null,correlation=null; + if(wire){ + const value=wire.Identity; + if(!value||typeof value.WorkerInstance!=="string"||value.WorkerInstance.length<1||value.WorkerInstance.length>128 + ||typeof value.Request!=="string"||typeof value.Generation!=="string" + ||!/^\d{1,20}$/.test(value.Request)||!/^\d{1,20}$/.test(value.Generation) + ||typeof wire.Correlation!=="string"||wire.Correlation.length<1||wire.Correlation.length>128) + throw new Error("unreadable diagnostic identity"); + identity={WorkerInstance:value.WorkerInstance,Request:value.Request,Generation:value.Generation}; + correlation=wire.Correlation; + } + const row={kind:"fsgg-worker-diagnostic/1",stage,status:"observed",identity,correlation, + elapsedMilliseconds:performance.now()-diagnosticStart}; + const bytes=new TextEncoder().encode(JSON.stringify(row)).length; + if(diagnosticEvents>=63||bytes>768||diagnosticBytes+bytes+512>12288){diagnosticStatus("truncated");return;} + diagnosticEvents++;diagnosticBytes+=bytes;self.postMessage(row); + }catch{diagnosticStatus("read-failed");} +}; let instance = null; let memoryExport = "memory"; const memory = () => instance.exports[memoryExport]; @@ -45,5 +80,15 @@ const receive = WorkerEntry_createWireRuntime({ }, error => fail(String(error))); }, Perform: perform -}, observation => self.postMessage(observation)); -self.onmessage = ({ data }) => receive(data); +}, observation => { + if(observation.Kind==="diagnostic"){diagnostic(observation.Phase,observation);return;} + if(observation.Kind==="loaded")diagnostic("load-completed",observation); + else if(observation.Kind==="failed")diagnostic("error",observation); + else if(["outcome","outcome-terminated"].includes(observation.Kind))diagnostic("terminal",observation); + self.postMessage(observation); +}); +self.onmessage = ({ data }) => { + if(data?.Kind==="load")diagnostic("load-received",data); + receive(data); +}; +diagnostic("handler-ready"); diff --git a/src/Wasm.Browser/js/worker-client.mjs b/src/Wasm.Browser/js/worker-client.mjs index 3584d153..b20c1216 100644 --- a/src/Wasm.Browser/js/worker-client.mjs +++ b/src/Wasm.Browser/js/worker-client.mjs @@ -3,26 +3,106 @@ export function moduleWorkerUrl(assetBaseUrl = import.meta.url) { return new URL("module-worker.mjs", assetBaseUrl); } -export function createHostTransport(assetBaseUrl = import.meta.url) { +export function createHostTransport(assetBaseUrl = import.meta.url, diagnostics) { const workers = new Map(), timers = new Map(); + let callback=null,include=null,readFailed=false,truncated=false,events=0,bytes=0,physical=0,workerMessages=0,unavailableWorkers=0,excludedEvents=0; + try{ + if(typeof diagnostics?.observe==="function")callback=diagnostics.observe; + if(diagnostics?.include!==undefined){if(typeof diagnostics.include!=="function")throw new Error("unreadable diagnostic scope");include=diagnostics.include;} + }catch{readFailed=true;} + const enabled=callback!==null; + const scalar=value=>{ + if(typeof value==="string"&&value.length<=512||typeof value==="boolean"||value===null + ||typeof value==="number"&&Number.isFinite(value))return value; + throw new Error("unreadable diagnostic scalar"); + }; + const record=(stage,fields=()=>({}))=>{ + if(!enabled||readFailed||truncated)return false; + try{ + const row=Object.freeze({stage,pageMonotonicMilliseconds:performance.now(),...fields()}); + if(include){const selected=include(row);if(typeof selected!=="boolean")throw new Error("unreadable diagnostic scope");if(!selected){excludedEvents++;return false;}} + const length=new TextEncoder().encode(JSON.stringify(row)).length; + if(events>=128||length>2048||bytes+length>24576){truncated=true;return false;} + events++;bytes+=length;callback(row);return true; + }catch{readFailed=true;return false;} + }; + const stages=new Set(["handler-ready","load-received","admission-begin","admission-end","sha256-begin","sha256-end", + "instantiate-begin","instantiate-end","load-completed","terminal","error","status"]); + const workerDiagnostic=(handle,data)=>{ + // Never forward even a perfectly correlated diagnostic to the reducer. + // These are advisory observations, not authenticated lifecycle receipts. + if(!enabled||readFailed||truncated)return; + try{ + if(workers.get(handle.name)!==handle)return; + if(!stages.has(data.stage)||!Number.isFinite(data.elapsedMilliseconds)||data.elapsedMilliseconds({physical:handle.physical,name:scalar(handle.name),workerStage:"status",status:data.status, + workerElapsedMilliseconds:data.elapsedMilliseconds}))){ + if(data.status==="truncated")truncated=true;else readFailed=true; + } + return; + } + if(data.status!=="observed")throw new Error("unreadable diagnostic status"); + let identity=null,correlation=null; + if(data.stage==="handler-ready"){ + if(handle.ready||data.identity!==null||data.correlation!==null)throw new Error("duplicate/foreign diagnostic readiness"); + handle.ready=true; + }else{ + const command=handle.command,value=data.identity; + if(!command||!value||value.WorkerInstance!==command.Identity.WorkerInstance||value.Request!==command.Identity.Request + ||value.Generation!==command.Identity.Generation||data.correlation!==command.Correlation) + throw new Error("foreign diagnostic request"); + identity=Object.freeze({WorkerInstance:scalar(value.WorkerInstance),Request:scalar(value.Request),Generation:scalar(value.Generation)}); + correlation=scalar(data.correlation); + } + if(record("worker-stage",()=>({physical:handle.physical,name:scalar(handle.name),workerStage:data.stage, + workerElapsedMilliseconds:data.elapsedMilliseconds,identity,correlation})))workerMessages++; + }catch{readFailed=true;} + }; return { - Now: () => BigInt(Math.floor(performance.now())), + // This snapshot does not grant readiness, authority or successful cleanup. + DiagnosticStatus:()=>Object.freeze({state:readFailed?"read-failed":!enabled?"disabled":truncated?"truncated":workerMessages===0?"unavailable":"observed", + enabled,available:workerMessages>0,readFailed,truncated,events,bytes,excludedEvents, + incomplete:!enabled||workerMessages===0||readFailed||truncated||unavailableWorkers>0 + ||[...workers.values()].some(value=>!value.ready),unavailableWorkers}), + Now() {const value=BigInt(Math.floor(performance.now()));record("now",()=>({value:Number(value)}));return value;}, CreateWorker(name, receive) { - const worker = new Worker(moduleWorkerUrl(assetBaseUrl), { type: "module", name }); - worker.onmessage = ({ data }) => receive(data); - workers.set(name, { worker, receive, command:null }); + const url=moduleWorkerUrl(assetBaseUrl); + if(enabled)url.hash="fsgg-worker-diagnostics-v1"; + const worker = new Worker(url, { type: "module", name }); + const handle={worker,receive,command:null,name,physical:++physical,ready:false,lastElapsed:0}; + worker.onmessage = ({ data }) => { + if(data?.kind==="fsgg-worker-diagnostic/1"){workerDiagnostic(handle,data);return;} + receive(data); + }; + workers.set(name, handle); + record("worker-created",()=>({physical:handle.physical,name:scalar(name)})); worker.onerror = event => { event.preventDefault(); - const command=workers.get(name)?.command; + // A retired physical Worker may report an error after a new candidate + // reuses its logical name. Never borrow that new Worker's command. + if(workers.get(name)!==handle)return; + const command=handle.command; if(command) receive({Identity:command.Identity,Correlation:command.Correlation,Kind:"failed",Phase:"compile",Digest:"",Version:0,State:"faulted",Status:0,Diagnostic:event.message,CleanupDiagnostic:"",Dispatched:false,Output:new Uint8Array()}); }; }, - PostCommand(name, command) { const handle=workers.get(name); handle.command=command; handle.worker.postMessage(command); }, - TerminateWorker(name) { workers.get(name)?.worker.terminate(); workers.delete(name); }, + PostCommand(name, command) { + const handle=workers.get(name);handle.command=command;handle.worker.postMessage(command); + record("post",()=>({physical:handle.physical,name:scalar(name),kind:scalar(command.Kind), + request:scalar(command.Identity.Request),generation:scalar(command.Identity.Generation),correlation:scalar(command.Correlation)})); + }, + TerminateWorker(name) { + const handle=workers.get(name);handle?.worker.terminate();workers.delete(name); + if(handle){if(enabled&&!handle.ready)unavailableWorkers++;record("worker-terminate",()=>({physical:handle.physical,name:scalar(name)}));} + }, ArmTimer(key, delay, observe) { clearTimeout(timers.get(key)); - timers.set(key, setTimeout(() => { timers.delete(key); observe(); }, delay)); + timers.set(key, setTimeout(() => { timers.delete(key);record("timer-fired",()=>({key:scalar(key)}));observe(); }, delay)); + record("timer-arm",()=>({key:scalar(key),delay:scalar(delay)})); }, - CancelTimer(key) { clearTimeout(timers.get(key)); timers.delete(key); } + CancelTimer(key) { clearTimeout(timers.get(key)); timers.delete(key);record("timer-cancel",()=>({key:scalar(key)})); } }; } diff --git a/src/Wasm.Contracts/compatibility-profile.v1.json b/src/Wasm.Contracts/compatibility-profile.v1.json index 59efff73..f2e20c5b 100644 --- a/src/Wasm.Contracts/compatibility-profile.v1.json +++ b/src/Wasm.Contracts/compatibility-profile.v1.json @@ -1,7 +1,7 @@ { "schema": "fsgg.wasm.compatibility-profile/v1", "packageId": "FS.GG.Wasm.Contracts", - "sourceVersion": "0.4.0", + "sourceVersion": "0.5.0", "published": false, "abiProfiles": [ { "id": "bar-abi-1", "version": 1, "prefix": "barc", "memory": "memory" }, diff --git a/tests/Wasm.Lifecycle.Tests/packages.lock.json b/tests/Wasm.Lifecycle.Tests/packages.lock.json index 3323fee0..f0b64e23 100644 --- a/tests/Wasm.Lifecycle.Tests/packages.lock.json +++ b/tests/Wasm.Lifecycle.Tests/packages.lock.json @@ -112,7 +112,7 @@ "fs.gg.wasm.browser": { "type": "Project", "dependencies": { - "FS.GG.Wasm.Contracts": "[0.4.0, )", + "FS.GG.Wasm.Contracts": "[0.5.0, )", "FSharp.Core": "[10.1.302, )" } }, @@ -124,4 +124,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/Wasm.PackageConsumer/Program.fs b/tests/Wasm.PackageConsumer/Program.fs index a148f5a9..06dabdf4 100644 --- a/tests/Wasm.PackageConsumer/Program.fs +++ b/tests/Wasm.PackageConsumer/Program.fs @@ -107,6 +107,27 @@ let createHost path digest outputCap deadline (transport: HostTransport) receive let load (host: Host) now wire artifact = host.Load(now, identity wire, artifact) +// The cold-start controls use the same real, pre-create enclosing deadline as +// a limited consumer operation. Phase observations cannot renew this budget. +let loadBoundedSc2 (host: Host) (now: int64) wire digest artifact deadline = + host.SubmitLimited( + now, + LimitedLoad(identity wire, configuration "sc2-imported-strict" digest 65536 deadline, artifact), + { MaximumDeadlineMilliseconds = deadline + MaximumOutputBytes = 65536 + EnclosingDeadlineMilliseconds = Some(now + int64 deadline) } + ) |> unwrap + +let prepareBoundedSc2 (host: Host) (now: int64) wire transaction digest artifact = + host.SubmitLimited( + now, + LimitedCandidate(identity wire, transaction, Some 1UL, + configuration "sc2-imported-strict" digest 65536 250, artifact), + { MaximumDeadlineMilliseconds = 250 + MaximumOutputBytes = 65536 + EnclosingDeadlineMilliseconds = Some(now + 250L) } + ) |> unwrap + let initialize (host: Host) now wire input = host.Initialize(now, identity wire, input) @@ -135,6 +156,8 @@ let prepare (host: Host) now wire transaction expected digest artifact = let validateCandidate (host: Host) now transaction generation = host.ValidateCandidate(now, transaction, generation) +let abortCandidate (host: Host) now transaction = host.AbortCandidate(now, transaction) + let commitCandidate (host: Host) now transaction expected generation = host.CommitCandidate(now, transaction, expected, generation) diff --git a/tests/Wasm.PackageConsumer/browser/package-consumer.mjs b/tests/Wasm.PackageConsumer/browser/package-consumer.mjs index 1c0c4410..df669980 100644 --- a/tests/Wasm.PackageConsumer/browser/package-consumer.mjs +++ b/tests/Wasm.PackageConsumer/browser/package-consumer.mjs @@ -2,14 +2,17 @@ import * as bridge from "./fable/Program.js"; import { createHostTransport } from "./_content/FS.GG.Wasm.Browser/worker-client.mjs"; const bytes = async name => new Uint8Array(await (await fetch(`./modules/${name}`)).arrayBuffer()); const digest = async value => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", value)), byte => byte.toString(16).padStart(2, "0")).join(""); -async function session(artifact, prefix, outputCap = 65536, deadline = 250, inject = false) { - const transport = createHostTransport(new URL("./_content/FS.GG.Wasm.Browser/", import.meta.url)); +async function session(artifact, prefix, outputCap = 65536, deadline = 250, inject = false, observer, holdLoaded = false) { + const transport = createHostTransport(new URL("./_content/FS.GG.Wasm.Browser/", import.meta.url),observer?{observe:observer}:undefined); const waiting = new Map(), results = [], posts=[], terminations=[], deliveryStates=[]; const terminate=transport.TerminateWorker; transport.TerminateWorker=name=>{terminations.push(name);terminate(name);}; - let ignored=0, host; + let ignored=0, host;const held=[]; const create=transport.CreateWorker; transport.CreateWorker=(name,receive)=>create(name,wire=>{ + // Declared delivery-impairment control: retain a genuine loaded message, + // not a manufactured ABI result. The real original timer still expires. + if(holdLoaded&&wire.Kind==="loaded"){held.push({wire,receive});return;} if(inject){ for(const wrong of [{...wire,Correlation:"wrong-correlation"},{...wire,Identity:{...wire.Identity,WorkerInstance:"wrong-worker"}},{...wire,Identity:{...wire.Identity,Generation:"99"}}]){ const before=JSON.stringify(bridge.project(host)), deliveries=results.length; @@ -22,7 +25,8 @@ async function session(artifact, prefix, outputCap = 65536, deadline = 250, inje }); const post=transport.PostCommand; transport.PostCommand=(name,command)=>{posts.push({name,Kind:command.Kind,request:command.Identity.Request});post(name,command);}; - host = bridge.createHost(prefix === "barc" ? "bar-protected" : "sc2-imported-strict", await digest(artifact), outputCap, deadline, transport, result => { + const artifactDigest=await digest(artifact); + host = bridge.createHost(prefix === "barc" ? "bar-protected" : "sc2-imported-strict", artifactDigest, outputCap, deadline, transport, result => { deliveryStates.push({request:result.Request,projection:bridge.project(host),terminations:[...terminations]}); results.push(result); const pending = waiting.get(result.Request); waiting.delete(result.Request); pending?.(result); }); @@ -32,15 +36,67 @@ async function session(artifact, prefix, outputCap = 65536, deadline = 250, inje return new Promise(resolve => { waiting.set(id, resolve); bridge[method](host, transport.Now(), identity, value); }); }; return { host, send, results, posts, terminations, deliveryStates, transport, + heldCount:()=>held.length, + releaseHeld:()=>{for(const row of held.splice(0))row.receive(row.wire);}, ignored:()=>ignored, prepare:async (artifact, transaction)=>{ const hash=await digest(artifact), id=String(++request), identity={WorkerInstance:"candidate-worker",Request:id,Generation:"2"}; return new Promise(resolve=>{waiting.set(id,resolve);bridge.prepare(host,transport.Now(),identity,transaction,1n,hash,artifact);}); }, + loadBounded:()=>{ + const id=String(++request),identity={WorkerInstance:"installed-worker",Request:id,Generation:"1"}; + return new Promise(resolve=>{waiting.set(id,resolve);bridge.loadBoundedSc2(host,transport.Now(),identity,artifactDigest,artifact,deadline);}); + }, + prepareBounded:transaction=>{ + const id=String(++request),identity={WorkerInstance:"candidate-worker",Request:id,Generation:"2"}; + return new Promise(resolve=>{waiting.set(id,resolve);bridge.prepareBoundedSc2(host,transport.Now(),identity,transaction,artifactDigest,artifact);}); + }, dispose() { bridge.dispose(host, transport.Now()); }, projection() { return bridge.project(host); } }; } + +// One declared history: fresh initial Worker, candidate abort, then a fresh +// same-name candidate. No priming, retry, synthetic clock or renewed budget. +window.runObservedFreshWorkers = async () => { + const trace=[],artifact=await bytes("control-ordinary.wasm"),current=await session(artifact,"sc2c",65536,250,false,row=>trace.push(row)); + let result={fixtureFailure:null}; + try{ + result.loaded=await current.loadBounded(); + if(result.loaded.State==="succeeded"){ + result.initialized=await current.send("initialize",new Uint8Array([0])); + if(result.initialized.State==="succeeded"){ + result.first=await current.prepareBounded("startup-first"); + if(result.first.State==="succeeded"){ + bridge.abortCandidate(current.host,current.transport.Now(),"startup-first"); + result.afterAbort=current.projection(); + result.second=await current.prepareBounded("startup-second"); + } + } + } + }catch(error){result.fixtureFailure=String(error).slice(0,256);} + finally{current.dispose();} + return {...result,trace,status:current.transport.DiagnosticStatus(),retired:current.terminations,final:current.projection()}; +}; + +window.runObservedColdExpiry = async () => { + const trace=[],artifact=await bytes("control-ordinary.wasm"),current=await session(artifact,"sc2c",65536,1,false,row=>trace.push(row)); + let loaded; + try{loaded=await current.loadBounded();}finally{current.dispose();} + return {loaded,trace,status:current.transport.DiagnosticStatus(),retired:current.terminations,final:current.projection()}; +}; + +window.runObservedLateLoaded = async () => { + const trace=[],artifact=await bytes("control-ordinary.wasm"); + const current=await session(artifact,"sc2c",65536,250,false,row=>trace.push(row),true); + let result; + try{ + const loaded=await current.loadBounded(),before=current.projection(),deliveriesBefore=current.results.length,held=current.heldCount(); + current.releaseHeld(); + result={loaded,held,before,after:current.projection(),deliveriesBefore,deliveriesAfter:current.results.length}; + }finally{current.dispose();} + return {...result,trace,status:current.transport.DiagnosticStatus(),retired:current.terminations}; +}; window.runGuest = async (name, prefix) => { const artifact = await bytes(name), current = await session(artifact, prefix); try { diff --git a/tests/Wasm.PackageConsumer/browser/package-consumer.spec.mjs b/tests/Wasm.PackageConsumer/browser/package-consumer.spec.mjs index 0db30f13..be4d1310 100644 --- a/tests/Wasm.PackageConsumer/browser/package-consumer.spec.mjs +++ b/tests/Wasm.PackageConsumer/browser/package-consumer.spec.mjs @@ -83,6 +83,54 @@ test("compile deadline terminates the actual freshly created Worker",async({page expect(value.active).toBe(""); }); +test("closed installed Worker starts fresh initially and after candidate abort under original limited budgets",async({page})=>{ + const requested=[],physical=[]; + const onRequest=request=>{const url=new URL(request.url());if(url.pathname.includes("/_content/FS.GG.Wasm.Browser/"))requested.push(url.pathname);}; + page.context().on("request",onRequest);page.on("worker",worker=>physical.push(worker.url())); + let value; + try{ + value=await page.evaluate(()=>window.runObservedFreshWorkers()); + const report={stage:"fresh installed Worker history",states:[value.loaded?.State,value.initialized?.State,value.first?.State,value.second?.State], + status:value.status,trace:value.trace}; + console.log(JSON.stringify(report)); + expect(value.fixtureFailure,JSON.stringify(report)).toBeNull(); + expect(report.states,JSON.stringify(report)).toEqual(["succeeded","succeeded","succeeded","succeeded"]); + expect(value.afterAbort.CandidateWorker).toBe(""); + expect(value.final).toMatchObject({Disposed:true,ActiveWorker:"",CandidateWorker:""}); + expect(physical).toHaveLength(3); + expect(physical.every(url=>new URL(url).pathname==="/sub/app/_content/FS.GG.Wasm.Browser/module-worker.mjs")).toBe(true); + expect(requested.some(url=>url.endsWith("/module-worker.mjs"))).toBe(true); + expect(requested.some(url=>url.includes("/policy/"))).toBe(false); + expect(value.status).toMatchObject({enabled:true,available:true,incomplete:false,truncated:false,readFailed:false}); + for(const id of [1,2,3]){ + const stages=value.trace.filter(row=>row.stage==="worker-stage"&&row.physical===id).map(row=>row.workerStage); + expect(stages.slice(0,9)).toEqual(["handler-ready","load-received","admission-begin","admission-end","sha256-begin","sha256-end","instantiate-begin","instantiate-end","load-completed"]); + } + const retired=value.trace.findIndex(row=>row.stage==="worker-terminate"&&row.physical===2); + const created=value.trace.findIndex(row=>row.stage==="worker-created"&&row.physical===3); + expect(retired).toBeGreaterThan(-1);expect(created).toBeGreaterThan(retired); + await expect.poll(()=>page.workers().length).toBe(0); + }finally{page.context().off("request",onRequest);} +}); + +test("observed cold expiry keeps its one-millisecond pre-create deadline and retirement",async({page})=>{ + const value=await page.evaluate(()=>window.runObservedColdExpiry()); + console.log(JSON.stringify({stage:"observed cold expiry",state:value.loaded.State,status:value.status,trace:value.trace})); + expect(value.loaded).toMatchObject({State:"timeout",Phase:"Compile"}); + expect(value.retired).toContain("installed-worker");expect(value.final.ActiveWorker).toBe(""); + await expect.poll(()=>page.workers().length).toBe(0); +}); + +test("a genuine loaded message withheld beyond the original deadline cannot restore expired authority",async({page})=>{ + const value=await page.evaluate(()=>window.runObservedLateLoaded()); + console.log(JSON.stringify({stage:"declared late delivery impairment",state:value.loaded.State,held:value.held,status:value.status,trace:value.trace})); + expect(value.held).toBe(1); // A real compile must occur; an unexercised prefix cannot pass. + expect(value.loaded.State).toBe("timeout");expect(value.deliveriesAfter).toBe(value.deliveriesBefore); + expect(value.after).toEqual(value.before);expect(value.after.ActiveWorker).toBe(""); + expect(value.retired).toContain("installed-worker"); + await expect.poll(()=>page.workers().length).toBe(0); +}); + test("foreign correlation, Worker and generation observations are inert on the installed host",async({page})=>{ const value=await page.evaluate(()=>window.runForeignObservations()); expect(value.ignored).toBeGreaterThan(9); diff --git a/tests/Wasm.Supervisor.Compatibility/Program.fs b/tests/Wasm.Supervisor.Compatibility/Program.fs index 284fb536..7efa786f 100644 --- a/tests/Wasm.Supervisor.Compatibility/Program.fs +++ b/tests/Wasm.Supervisor.Compatibility/Program.fs @@ -176,6 +176,13 @@ let workerLimits () = async { send (id "worker" 1UL 1UL) ceiling (InspectAndCompile(ceiling,sc2Module)) let! loaded=terminal 1000 check (loaded.Kind="loaded") (sprintf "actual Worker wire loaded ceiling %A" loaded) + let diagnostics=observations |> List.filter(fun value -> value.Kind="diagnostic") + check ((diagnostics |> List.map(fun value -> value.Phase)) = + ["admission-begin";"admission-end";"sha256-begin";"sha256-end";"instantiate-begin";"instantiate-end"]) + "load diagnostics observe actual policy/mechanical boundaries in order" + for value in diagnostics do + check (value.Output.Length=0 && not value.Dispatched && value.Diagnostic="") "diagnostics contain no guest bytes/diagnostic text or ABI claim" + check (WorkerEntry.observationFromWire value |> Result.isError) "diagnostics cannot become lifecycle observations" send (id "worker" 2UL 1UL) (narrow 10 44) (InitializeGuest [|1uy|]) let! initialized=terminal 1000 check (initialized.State="succeeded" && initialized.Output.Length=44) "44-byte narrowed init succeeds without reload" diff --git a/tests/release/wasm/test-api-baseline-policy.py b/tests/release/wasm/test-api-baseline-policy.py index 6f185d74..f53538d2 100644 --- a/tests/release/wasm/test-api-baseline-policy.py +++ b/tests/release/wasm/test-api-baseline-policy.py @@ -71,7 +71,8 @@ def module(name, file): else: raise SystemExit("additive successor accepted unavailable/breaking native verdict") assert manifest.api_policy(current_policy["candidateVersion"]) == current_policy assert current_policy["candidateVersion"] == ET.parse(ROOT / "eng/wasm-shared/version.props").findtext("./PropertyGroup/WasmSharedVersion") -assert current_policy["baselineVersion"] == manifest.api_policy("0.3.0")["candidateVersion"] +assert current_policy["baselineVersion"] == manifest.api_policy("0.4.0")["candidateVersion"] +assert manifest.api_policy("0.4.0")["baselineVersion"] == "0.3.0" assert manifest.api_policy("0.3.0")["baselineVersion"] == "0.2.0" assert manifest.api_policy("0.2.0")["baselineVersion"] == "0.1.1" try: manifest.api_policy("0.2.1") diff --git a/tests/release/wasm/test-installed-evidence.py b/tests/release/wasm/test-installed-evidence.py index 0f58aacd..257f4d67 100644 --- a/tests/release/wasm/test-installed-evidence.py +++ b/tests/release/wasm/test-installed-evidence.py @@ -30,16 +30,16 @@ def test_hidden_metadata_and_colon_cache_exclusion(self): root=pathlib.Path(raw);work=root/'work' for name in ('release-manifest.json','NuGet.Config','consumer/packages.lock.json'): put(work/name,b'{}') - put(work/'sdk-artifacts/fsgg-wasm-sdk-0.4.0.tar.gz') + put(work/'sdk-artifacts/fsgg-wasm-sdk-0.5.0.tar.gz') put(work/'installed-member-receipt.log') put(work/'consumer-http.log') for ident in e.PACKAGES: - for leaf in (ident+'.0.4.0.nupkg',ident+'.0.4.0.nupkg.sha512','.nupkg.metadata'): - put(work/'consumer-packages'/ident/'0.4.0'/leaf) + for leaf in (ident+'.0.5.0.nupkg',ident+'.0.5.0.nupkg.sha512','.nupkg.metadata'): + put(work/'consumer-packages'/ident/'0.5.0'/leaf) for name in ('fsharp.core.10.1.302.nupkg','fsharp.core.10.1.302.nupkg.sha512','.nupkg.metadata','lib/netstandard2.0/FSharp.Core.dll','lib/netstandard2.1/FSharp.Core.dll'): put(work/'consumer-packages/fsharp.core/10.1.302'/name) put(work/'consumer/browser/test-results/.last-run.json',b'{"status":"passed"}') - for name in ('FSharp.Core.dll','Consumer.dll','Consumer','fable/Program.js','_content/FS.GG.Wasm.Browser/module-worker.mjs','_content/FS.GG.Wasm.Browser/worker-client.mjs','_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js'): + for name in ('FSharp.Core.dll','Consumer.dll','Consumer','fable/Program.js','_content/FS.GG.Wasm.Browser/module-worker.mjs','_content/FS.GG.Wasm.Browser/worker-client.mjs','_content/FS.GG.Wasm.Browser/worker-build.json','_content/FS.GG.Wasm.Browser/policy/WorkerEntry.js'): put(work/'consumer/public/sub/app'/name) put(work/'http-cache/cache:https.json/list.dat',b'discard HTTP cache') p=e.Projection();e.consumer(p,work) @@ -80,7 +80,7 @@ def test_complete_fable_dependency_roles_and_original_archive_joins(self): with self.subTest(context=context),tempfile.TemporaryDirectory() as raw: root=pathlib.Path(raw);p=e.Projection();owner=context.split('/')[0] for package,body in archives.items(): - p.add(put(root/package,body),owner+'/packages/'+package+'/'+package+'.0.4.0.nupkg') + p.add(put(root/package,body),owner+'/packages/'+package+'/'+package+'.0.5.0.nupkg') dependency=root/'compiled/fable_modules' put(dependency/'.gitignore',b'*') library=dependency/'fable-library-js.5.18.0' @@ -88,7 +88,7 @@ def test_complete_fable_dependency_roles_and_original_archive_joins(self): ('package.json',b'{"name":"@fable-org/fable-library-js","version":"2.8.0","type":"module"}'), ('Array.js',b'actual runtime'),('lib/big.js',b'actual nested runtime')):put(library/leaf,body) for package,module in (('fs.gg.wasm.browser','FS.GG.Wasm.Browser'),('fs.gg.wasm.contracts','FS.GG.Wasm.Contracts')): - folder=dependency/(module+'.0.4.0') + folder=dependency/(module+'.0.5.0') for member,body in members[package].items(): if not member.startswith('fable/'):continue leaf=member[len('fable/'):] @@ -97,13 +97,13 @@ def test_complete_fable_dependency_roles_and_original_archive_joins(self): if leaf.endswith('.fs'):put(folder/(leaf+'.js'),b'generated current source') p.tree(root/'compiled',context) self.assertFalse(p.errors,p.errors) - self.assertIn(context+'/fable_modules/FS.GG.Wasm.Browser.0.4.0/Host.fs',p.files) - self.assertIn(context+'/fable_modules/FS.GG.Wasm.Browser.0.4.0/Host.fs.js',p.files) + self.assertIn(context+'/fable_modules/FS.GG.Wasm.Browser.0.5.0/Host.fs',p.files) + self.assertIn(context+'/fable_modules/FS.GG.Wasm.Browser.0.5.0/Host.fs.js',p.files) if owner=='consumer': self.assertEqual(p.finish(root/'out',0),0) joined=e.Projection();e.merge(joined,root/'out') self.assertFalse(joined.errors,joined.errors);self.assertEqual(joined.files,p.files) - copied=context+'/fable_modules/FS.GG.Wasm.Browser.0.4.0/Host.fs' + copied=context+'/fable_modules/FS.GG.Wasm.Browser.0.5.0/Host.fs' original=p.files[copied];p.files[copied]=b'changed copied source' p.finish(root/'forged',0) refused=e.Projection();e.merge(refused,root/'forged') @@ -118,8 +118,8 @@ def test_complete_fable_dependency_roles_and_original_archive_joins(self): bad.dependency(context+'/fable_modules/fable-library-js.5.18.0/Array.js') self.assertTrue(any(x['reason']=='Fable dependency differs from original package member' for x in bad.errors),bad.errors) for leaf in ('fable-library-js.5.18.0/unexpected.exe','fable-library-js.5.18.0/foreign.js', - 'Other.0.4.0/Host.fs','FS.GG.Wasm.Browser.0.4.0/Foreign.fs', - 'FS.GG.Wasm.Browser.0.4.0/Foreign.fs.js'): + 'Other.0.5.0/Host.fs','FS.GG.Wasm.Browser.0.5.0/Foreign.fs', + 'FS.GG.Wasm.Browser.0.5.0/Foreign.fs.js'): name=context+'/fable_modules/'+leaf if leaf.endswith('foreign.js'): bad.files[name]=b'foreign runtime';bad.dependency(name) diff --git a/tests/release/wasm/test-release-promotion.py b/tests/release/wasm/test-release-promotion.py index a10dfcb9..8adceef1 100644 --- a/tests/release/wasm/test-release-promotion.py +++ b/tests/release/wasm/test-release-promotion.py @@ -84,8 +84,8 @@ class Occupancy: def __init__(self,mutant=None): self.mutant=mutant; self.writes=[] def pages(self,path,key=None): if self.mutant=='denied': raise p.Refusal('403 Unknown') - if self.mutant=='deleted' and 'state=deleted' in path: return [{'name':'0.4.0','id':1}] - if self.mutant=='active' and 'state=active' in path: return [{'name':'0.4.0','id':1}] + if self.mutant=='deleted' and 'state=deleted' in path: return [{'name':'0.5.0','id':1}] + if self.mutant=='active' and 'state=active' in path: return [{'name':'0.5.0','id':1}] if self.mutant=='malformed-org': return [{'id':1}] return [{'name':'0.2.0','id':1}] def request(self,url,**kwargs): @@ -94,7 +94,7 @@ def request(self,url,**kwargs): if url.endswith('/index.json'): if self.mutant=='missing-index': return 404,None,{} if self.mutant=='malformed-index': return 200,{'versions':[{}]},{} - return 200,{'versions':['0.1.1','0.2.0']+(['0.4.0'] if self.mutant=='unlisted' else [])},{} + return 200,{'versions':['0.1.1','0.2.0']+(['0.5.0'] if self.mutant=='unlisted' else [])},{} return (200 if self.mutant in ('contradiction','unlisted') else 404),b'',{} class Tests(unittest.TestCase): @@ -424,20 +424,20 @@ def test_installed_cache_member_lock_worker_substitution(self): root=pathlib.Path(raw);cache=root/'packages';record=root/'promotion-binding.json';lock=root/'packages.lock.json' r=reviewed();dependencies={} for ident in p.PACKAGES: - folder=cache/ident.lower()/'0.4.0';folder.mkdir(parents=True) - archive=folder/(ident.lower()+'.0.4.0.nupkg') + folder=cache/ident.lower()/'0.5.0';folder.mkdir(parents=True) + archive=folder/(ident.lower()+'.0.5.0.nupkg') with zipfile.ZipFile(archive,'w') as z:z.writestr('ordinary.dll',b'real-payload');z.writestr('module-worker.mjs',b'real-worker') - r['binding']['archives'][ident+'.0.4.0.nupkg']=p.digest(archive.read_bytes()) - r['binding']['inventories'][ident+'.0.4.0.nupkg']={'ordinary.dll':p.digest(b'real-payload'),'module-worker.mjs':p.digest(b'real-worker')} + r['binding']['archives'][ident+'.0.5.0.nupkg']=p.digest(archive.read_bytes()) + r['binding']['inventories'][ident+'.0.5.0.nupkg']={'ordinary.dll':p.digest(b'real-payload'),'module-worker.mjs':p.digest(b'real-worker')} content=base64.b64encode(hashlib.sha512(archive.read_bytes()).digest()).decode() - (folder/(ident.lower()+'.0.4.0.nupkg.sha512')).write_text(content) + (folder/(ident.lower()+'.0.5.0.nupkg.sha512')).write_text(content) (folder/'.nupkg.metadata').write_text(json.dumps({'version':2,'contentHash':content,'source':'https://nuget.pkg.github.com/FS-GG/index.json'})) - dependencies[ident]={'resolved':'0.4.0','contentHash':content} + dependencies[ident]={'resolved':'0.5.0','contentHash':content} record.write_text(json.dumps({'preparation':r}));lock.write_text(json.dumps({'dependencies':{'net10.0':dependencies}})) with patch.dict(os.environ,{'ACCEPTED_EXECUTOR':p.P}): p.check_installed(record,cache,'https://nuget.pkg.github.com/FS-GG/index.json',lock) - ident=p.PACKAGES[1];folder=cache/ident.lower()/'0.4.0';archive=folder/(ident.lower()+'.0.4.0.nupkg');original=archive.read_bytes() - sidecar=folder/(ident.lower()+'.0.4.0.nupkg.sha512');metadata=folder/'.nupkg.metadata';original_sidecar=sidecar.read_text();original_metadata=metadata.read_text() + ident=p.PACKAGES[1];folder=cache/ident.lower()/'0.5.0';archive=folder/(ident.lower()+'.0.5.0.nupkg');original=archive.read_bytes() + sidecar=folder/(ident.lower()+'.0.5.0.nupkg.sha512');metadata=folder/'.nupkg.metadata';original_sidecar=sidecar.read_text();original_metadata=metadata.read_text() for mutation in ('worker','ordinary','signature-disguised-worker','lock','version','missing-cache'): saved=copy.deepcopy(dependencies) if mutation in ('worker','ordinary','signature-disguised-worker'): @@ -553,17 +553,17 @@ def test_installed_signed_hash_roles_are_independent_and_payload_locked(self): with tempfile.TemporaryDirectory() as raw,patch.dict(os.environ,{'ACCEPTED_EXECUTOR':p.P}): root=pathlib.Path(raw);cache=root/'packages';record=root/'promotion-binding.json';lock=root/'packages.lock.json';r=reviewed();deps={};originals={} for ident in p.PACKAGES: - folder=cache/ident.lower()/'0.4.0';folder.mkdir(parents=True);archive=folder/(ident.lower()+'.0.4.0.nupkg') + folder=cache/ident.lower()/'0.5.0';folder.mkdir(parents=True);archive=folder/(ident.lower()+'.0.5.0.nupkg') unsigned=self.package_fixture();signed=self.package_fixture(1);archive.write_bytes(signed);originals[ident]=signed - r['binding']['archives'][ident+'.0.4.0.nupkg']=p.digest(unsigned) - with p.zipfile.ZipFile(io.BytesIO(unsigned))as z:r['binding']['inventories'][ident+'.0.4.0.nupkg']={name:p.digest(z.read(name))for name in z.namelist()} - content=base64.b64encode(hashlib.sha512(unsigned).digest()).decode();self.assertNotEqual(base64.b64encode(hashlib.sha512(signed).digest()).decode(),content);deps[ident]={'resolved':'0.4.0','contentHash':content} - (folder/(ident.lower()+'.0.4.0.nupkg.sha512')).write_text(base64.b64encode(hashlib.sha512(signed).digest()).decode()) + r['binding']['archives'][ident+'.0.5.0.nupkg']=p.digest(unsigned) + with p.zipfile.ZipFile(io.BytesIO(unsigned))as z:r['binding']['inventories'][ident+'.0.5.0.nupkg']={name:p.digest(z.read(name))for name in z.namelist()} + content=base64.b64encode(hashlib.sha512(unsigned).digest()).decode();self.assertNotEqual(base64.b64encode(hashlib.sha512(signed).digest()).decode(),content);deps[ident]={'resolved':'0.5.0','contentHash':content} + (folder/(ident.lower()+'.0.5.0.nupkg.sha512')).write_text(base64.b64encode(hashlib.sha512(signed).digest()).decode()) (folder/'.nupkg.metadata').write_text(json.dumps({'version':2,'contentHash':content,'source':feed})) record.write_text(json.dumps({'preparation':r}));lock.write_text(json.dumps({'dependencies':{'net10.0':deps}})) with patch.object(p,'repository_signature',return_value='source-controlled verification fixture')as verify: p.check_installed(record,cache,feed,lock);self.assertEqual(verify.call_count,2) - ident=p.PACKAGES[0];folder=cache/ident.lower()/'0.4.0';archive=folder/(ident.lower()+'.0.4.0.nupkg');sidecar=folder/(ident.lower()+'.0.4.0.nupkg.sha512');metadata=folder/'.nupkg.metadata' + ident=p.PACKAGES[0];folder=cache/ident.lower()/'0.5.0';archive=folder/(ident.lower()+'.0.5.0.nupkg');sidecar=folder/(ident.lower()+'.0.5.0.nupkg.sha512');metadata=folder/'.nupkg.metadata' baseline_sidecar=sidecar.read_text();baseline_metadata=metadata.read_text();baseline_deps=copy.deepcopy(deps) for mutation in ('sidecar','metadata-hash','metadata-source','metadata-version','metadata-malformed','metadata-missing','metadata-duplicate','lock','unsigned-public','ordinary','payload','corrupt-compression','cache-drift','signature-trust','foreign-feed'): if mutation=='sidecar':sidecar.write_text(deps[ident]['contentHash']) @@ -651,7 +651,7 @@ def pages(self,*args,**kwargs):return jobs def test_foreign_occupied_second_member_blocks_first_writer(self): class Transport(Occupancy): def pages(self,path,key=None): - return [{'name':'0.4.0','id':1}] if 'browser' in path and 'state=active' in path else [] + return [{'name':'0.5.0','id':1}] if 'browser' in path and 'state=active' in path else [] calls=[] def reject(args,**kwargs): calls.append(args[0]) @@ -669,11 +669,11 @@ def test_recovery_begin_checks_occupied_payload_before_any_writer(self): class Transport(Occupancy): def pages(self,path,key=None): if outcome=='unknown':raise p.Refusal('403 occupancy Unknown') - return [{'name':'0.4.0','id':1}] if feed=='org' and 'browser' in path and 'state=active' in path else [] + return [{'name':'0.5.0','id':1}] if feed=='org' and 'browser' in path and 'state=active' in path else [] def request(self,url,**kwargs): if feed!='public' or url.endswith('/v3/index.json'):return super().request(url,**kwargs) exists='browser' in url - return (200,{'versions':['0.2.0']+(['0.4.0'] if exists else [])},{}) if url.endswith('/index.json') else (200 if exists else 404,b'',{}) + return (200,{'versions':['0.2.0']+(['0.5.0'] if exists else [])},{}) if url.endswith('/index.json') else (200 if exists else 404,b'',{}) def checked(self,path,method='GET',data=None): if method=='POST':events.append(('writer','draft')) return {'id':1,'draft':True,'assets':[]} @@ -696,7 +696,7 @@ def test_recovery_admission_requires_occupied_payload_readback(self): class Transport(Occupancy): def pages(self,path,key=None): if outcome=='unknown':raise p.Refusal('403 occupancy Unknown') - return [{'name':'0.4.0','id':1}] if 'browser' in path and 'state=active' in path else [] + return [{'name':'0.5.0','id':1}] if 'browser' in path and 'state=active' in path else [] def cmd(args,**kwargs): self.assertTrue(str(args[0]).endswith('readback.sh')) events.append(('readback',args[1],args[-1])) @@ -730,12 +730,14 @@ def test_actual_clean_executor_and_finite_glue_delta(self): # Actual Git cleanliness/head checks remain live; the complete diff parser # is exercised independently against real inert Git commits by collector controls. with patch.dict(os.environ,env),patch.object(binder,'source_audit',return_value={'bound':True}) as audit: - audit_row=binder.bind_executor(binder.PUBLISHED,head,p.VERSION) + # This read-only binder remains finite to authentic published 0.4; + # selecting current source 0.5 must not invent its publication P. + audit_row=binder.bind_executor(binder.PUBLISHED,head,'0.4.0') self.assertTrue(audit_row['bound']);self.assertEqual(audit_row['execution']['run'],707) audit.assert_called_once_with(binder.PUBLISHED,head) for key,bad in [('GITHUB_SHA','9'*40),('GITHUB_WORKFLOW_SHA','9'*40),('QUALIFIER_SOURCE','9'*40),('GITHUB_REF','refs/heads/other'),('GITHUB_EVENT_NAME','push'),('GITHUB_RUN_ATTEMPT','2'),('GITHUB_WORKFLOW_REF',p.REPO+'/.github/workflows/other.yml@refs/heads/main'),('GITHUB_JOB','other'),('GITHUB_RUN_ID','0'),('WASM_PACKAGE_FEED','https://other.invalid/index.json')]: - with self.subTest(key=key),patch.dict(os.environ,{key:bad}),self.assertRaises(AssertionError):binder.bind_executor(binder.PUBLISHED,head,p.VERSION) - for executor,version in [('9'*40,p.VERSION),(head,'0.3.0')]: + with self.subTest(key=key),patch.dict(os.environ,{key:bad}),self.assertRaises(AssertionError):binder.bind_executor(binder.PUBLISHED,head,'0.4.0') + for executor,version in [('9'*40,'0.4.0'),(head,'0.3.0'),(head,p.VERSION)]: with self.assertRaises(AssertionError):binder.bind_executor(binder.PUBLISHED,executor,version) with patch.dict(os.environ,{'EXECUTION_BINDING':'{"schema":"historical"}'}),self.assertRaises(p.Refusal):p.execution_input() @@ -745,15 +747,15 @@ def test_installed_binder_authenticates_original_publication_and_current_qualifi producer=binder.PUBLISHED;tree=binder.PUBLISHED_TREE;qualifier='9'*40 with patch.object(p,'P',producer),patch.object(p,'TREE',tree): r=reviewed();run,jobs,artifact=native_data() - journal={'schema':'fsgg.wasm.promotion/v1','preparation':r,'firstPromotionRun':201,'firstPromotionAttempt':1,'producer':producer,'tag':'wasm/v0.4.0'} - values={'PUBLISHED_SOURCE':producer,'QUALIFIER_SOURCE':qualifier,'ACCEPTED_EXECUTOR':qualifier,'RELEASE_VERSION':'0.4.0'} + journal={'schema':'fsgg.wasm.promotion/v1','preparation':r,'firstPromotionRun':201,'firstPromotionAttempt':1,'producer':producer,'tag':'wasm/v0.5.0'} + values={'PUBLISHED_SOURCE':producer,'QUALIFIER_SOURCE':qualifier,'ACCEPTED_EXECUTOR':qualifier,'RELEASE_VERSION':'0.5.0'} class Transport: def __init__(self,mutation=None): self.mutation=mutation;self.journal=copy.deepcopy(journal) if mutation=='journal-producer':self.journal['producer']=qualifier body=json.dumps(self.journal).encode();self.body=body self.asset={'id':401,'name':'promotion-binding.json','url':f'https://api.github.com/repos/{p.REPO}/releases/assets/401','size':len(body),'digest':'sha256:'+p.digest(body)} - self.release={'id':300,'tag_name':'wasm/v0.4.0','target_commitish':qualifier if mutation=='release-source' else producer,'draft':False,'prerelease':False,'assets':[self.asset]} + self.release={'id':300,'tag_name':'wasm/v0.5.0','target_commitish':qualifier if mutation=='release-source' else producer,'draft':False,'prerelease':False,'assets':[self.asset]} def checked(self,path): if path.endswith('/git/ref/heads/main'):return {'object':{'sha':'8'*40 if self.mutation=='main' else qualifier}} if path.endswith('/releases/300'):return self.release @@ -813,12 +815,12 @@ def checked(self,path,method='GET',data=None): return release def pages(self,path,key=None): ident=p.PACKAGES[0] if 'contracts' in path else p.PACKAGES[1] - return [{'name':'0.4.0','id':1}] if 'state=active' in path and state.get(('org',ident)) else [] + return [{'name':'0.5.0','id':1}] if 'state=active' in path and state.get(('org',ident)) else [] def request(self,url,**kw): if url.endswith('/v3/index.json'):return super().request(url,**kw) ident=p.PACKAGES[0] if 'contracts' in url else p.PACKAGES[1] exists=state.get(('public',ident)) - return (200,{'versions':['0.2.0']+(['0.4.0'] if exists else [])},{}) if url.endswith('/index.json') else (200 if exists else 404,b'',{}) + return (200,{'versions':['0.2.0']+(['0.5.0'] if exists else [])},{}) if url.endswith('/index.json') else (200 if exists else 404,b'',{}) def rb(kind,custody,output): if kind in ('org','public'): if not all(state.get((kind,x)) for x in p.PACKAGES):raise p.Refusal('pair absent') @@ -854,7 +856,7 @@ def cmd(args,**kw): self.assertEqual(events.count('public-contracts'),1);self.assertEqual(events.count('public-browser'),1) def test_draft_resolver_actual_transport_pagination_and_unknowns(self): - release={'id':402763234,'tag_name':'wasm/v0.4.0','target_commitish':p.P,'draft':True,'prerelease':False,'assets':[]} + release={'id':402763234,'tag_name':'wasm/v0.5.0','target_commitish':p.P,'draft':True,'prerelease':False,'assets':[]} class Transport(p.Native): def __init__(self,mutation=None):super().__init__();self.mutation=mutation;self.calls=[] def api(self,path,method='GET',data=None): @@ -908,8 +910,8 @@ def test_asset_shell_callers_use_actual_shared_resolver(self): # are covered separately; no product or provider process runs here. with tempfile.TemporaryDirectory(prefix='wasm-asset-caller-control.')as raw: root=pathlib.Path(raw);tools=root/'bin';tools.mkdir();custody=root/'custody';custody.mkdir() - for name in ('fsgg-wasm-sdk-0.4.0.tar.gz','release-manifest.json','SHA256SUMS'):(custody/name).write_bytes(name.encode()) - (custody/'release-manifest.json').write_text(json.dumps({'version':'0.4.0'})) + for name in ('fsgg-wasm-sdk-0.5.0.tar.gz','release-manifest.json','SHA256SUMS'):(custody/name).write_bytes(name.encode()) + (custody/'release-manifest.json').write_text(json.dumps({'version':'0.5.0'})) (root/'reviewed.json').write_text(json.dumps(reviewed())) wrapper=tools/'python3';wrapper.write_text('#!'+sys.executable+'\n'+'''import importlib.util,sys,os,json,pathlib,hashlib,io,zipfile sys.dont_write_bytecode=True @@ -919,14 +921,14 @@ def test_asset_shell_callers_use_actual_shared_resolver(self): sys.exit(subprocess.call([sys.executable]+args)) s=importlib.util.spec_from_file_location('promotion',args[0]);p=importlib.util.module_from_spec(s);s.loader.exec_module(p) root=pathlib.Path(os.environ['FIXTURE_ROOT']);c=root/'custody' -r=json.loads((root/'reviewed.json').read_text());journal={'schema':'fsgg.wasm.promotion/v1','preparation':r,'firstPromotionRun':200,'firstPromotionAttempt':1,'producer':p.P,'tag':'wasm/v0.4.0'} +r=json.loads((root/'reviewed.json').read_text());journal={'schema':'fsgg.wasm.promotion/v1','preparation':r,'firstPromotionRun':200,'firstPromotionAttempt':1,'producer':p.P,'tag':'wasm/v0.5.0'} (c/'promotion-binding.json').write_text(json.dumps(journal)) record={'schema':'fsgg.wasm.admission/v1','reviewed':r,'mode':'promote','run':200,'attempt':1,'exchangeVerified':True,'occupancy':{k:False for k in p.OCCUPANCY_KEYS},'observedAt':'now','nugetAccountSha256':p.digest(b'offline'),'publicModeratorRemovalHistory':'Unknown','atomicReservation':'Unknown'} raw=io.BytesIO() with zipfile.ZipFile(raw,'w')as z:z.writestr('admission-binding.json',json.dumps(record)) admission=raw.getvalue() -rows=[dict(id=i+1,name=n,url=f'https://api.github.com/repos/{p.REPO}/releases/assets/{i+1}',size=len((c/n).read_bytes()),digest='sha256:'+p.digest((c/n).read_bytes()))for i,n in enumerate(('fsgg-wasm-sdk-0.4.0.tar.gz','release-manifest.json','SHA256SUMS','promotion-binding.json'))] -release=dict(id=402763234,tag_name='wasm/v0.4.0',target_commitish=p.P,draft=True,prerelease=False,assets=rows) +rows=[dict(id=i+1,name=n,url=f'https://api.github.com/repos/{p.REPO}/releases/assets/{i+1}',size=len((c/n).read_bytes()),digest='sha256:'+p.digest((c/n).read_bytes()))for i,n in enumerate(('fsgg-wasm-sdk-0.5.0.tar.gz','release-manifest.json','SHA256SUMS','promotion-binding.json'))] +release=dict(id=402763234,tag_name='wasm/v0.5.0',target_commitish=p.P,draft=True,prerelease=False,assets=rows) class N(p.Native): def api(self,path,method='GET',data=None): assert method=='GET' diff --git a/tests/release/wasm/test-release-wasm.py b/tests/release/wasm/test-release-wasm.py index 8700520d..7f996d2a 100755 --- a/tests/release/wasm/test-release-wasm.py +++ b/tests/release/wasm/test-release-wasm.py @@ -123,7 +123,7 @@ def source_checks() -> None: props = (ROOT / "eng/wasm-shared/version.props").read_text() policy=json.loads((ROOT / "scripts/wasm-release/api-baseline-policy.json").read_text()) version=policy["candidateVersion"] - require(version == "0.4.0" and f"{version}" in props, "selected version origin mismatch") + require(version == "0.5.0" and f"{version}" in props, "selected version origin mismatch") for path in (ROOT / "src/Wasm.Contracts/FS.GG.Wasm.Contracts.fsproj", ROOT / "src/Wasm.Browser/FS.GG.Wasm.Browser.fsproj"): require("$(WasmSharedVersion)" in path.read_text(), f"{path} bypasses shared version origin") require((ROOT / "sdk/wasm/VERSION").read_text().strip() == version, "SDK version mismatch") @@ -137,6 +137,8 @@ def source_checks() -> None: validate_workflow(workflow) supervisor_backend_checks() subprocess.run(["python3", str(ROOT / "tests/release/wasm/test-quint-evaluator-preparation.py")], check=True) + subprocess.run(["node", "--test", str(ROOT / "tests/release/wasm/test-worker-transport.mjs")], check=True) + subprocess.run(["python3", "-B", str(ROOT / "tests/release/wasm/test-worker-bundle-custody.py")], check=True) browser_setup_checks() subprocess.run(["python3", "-B", str(ROOT / "tests/release/wasm/test-installed-org-workflow.py")], check=True) promotion_tests = ["python3", "-B", str(ROOT / "tests/release/wasm/test-release-promotion.py")] diff --git a/tests/release/wasm/test-worker-bundle-custody.py b/tests/release/wasm/test-worker-bundle-custody.py new file mode 100644 index 00000000..ce65ad51 --- /dev/null +++ b/tests/release/wasm/test-worker-bundle-custody.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Actual package-reader controls over synthetic bytes; no compiler/JS executes.""" +import copy +import hashlib +import importlib.util +import json +import pathlib +import sys +import tempfile +import unittest +import zipfile + +ROOT=pathlib.Path(__file__).resolve().parents[3] +sys.dont_write_bytecode=True +spec=importlib.util.spec_from_file_location("worker_manifest",ROOT/"scripts/wasm-release/release_manifest.py") +manifest=importlib.util.module_from_spec(spec);spec.loader.exec_module(manifest) +PREFIX="contentFiles/any/any/_content/FS.GG.Wasm.Browser/" +SOURCE="1"*40 +sha=lambda value:hashlib.sha256(value).hexdigest() + +def fixture(): + data={} + for name in ("RuntimeProtocol","Admission","Invocation","WorkerEntry","Lifecycle","Host"): + data["api-surface/"+name+".fsi"]=b"// synthetic API role, not compiled\n" + data.update({"fable/js/wasm-primitives.js":b"// synthetic mechanical role\n", + "fable/eng/wasm-shared/version.props":b"0.5.0", + "fable/FS.GG.Wasm.Browser.fsproj":b'', + "fable/js/module-worker.entry.mjs":(ROOT/"src/Wasm.Browser/js/module-worker.mjs").read_bytes(), + "buildTransitive/FS.GG.Wasm.Browser.targets":b"", + PREFIX+"module-worker.mjs":b"self.onmessage=()=>{}; // synthetic byte-join fixture only\n", + PREFIX+"worker-client.mjs":b"// synthetic transport role\n", + "build-provenance/worker-bundle/package-lock.json":(ROOT/"scripts/wasm-release/worker-bundle/package-lock.json").read_bytes()}) + inputs=[] + for name in sorted(["WorkerEntry.js","Admission.js","Invocation.js","RuntimeProtocol.js","Wasm.Contracts/Contracts.js"]): + value=b"// synthetic generated role: "+name.encode()+b"\n";data[PREFIX+"policy/"+name]=value + inputs.append({"path":name,"bytes":len(value),"sha256":sha(value)}) + data["FS.GG.Wasm.Browser.nuspec"]=(f'FS.GG.Wasm.Browser0.5.0').encode() + bundle=data[PREFIX+"module-worker.mjs"];entry=data["fable/js/module-worker.entry.mjs"] + record={"schema":"fsgg.wasm.worker-build/1","sourceCommit":SOURCE,"sourceTree":"2"*40, + "tool":{"name":"esbuild","version":"0.28.2","binarySha256":"a"*64,"lockSha256":sha(data["build-provenance/worker-bundle/package-lock.json"])}, + "syntaxParser":{"name":"acorn","version":"8.19.0"},"scopeAnalyzer":{"name":"eslint-scope","version":"9.1.2"},"fableVersion":"5.18.0", + "mechanicalEntry":{"path":"src/Wasm.Browser/js/module-worker.mjs","bytes":len(entry),"sha256":sha(entry)}, + "generatedInputs":inputs,"output":{"path":"module-worker.mjs","bytes":len(bundle),"sha256":sha(bundle)}, + "composition":{"format":"esm","platform":"browser","target":"es2022","minify":False,"treeShaking":False,"keepNames":True, + "deterministicBuildsCompared":2,"outputFiles":1,"externalImports":0}} + return data,record + +def inspect(data,record): + with tempfile.TemporaryDirectory(prefix="worker-custody-inert.") as raw: + package=pathlib.Path(raw)/"FS.GG.Wasm.Browser.0.5.0.nupkg" + with zipfile.ZipFile(package,"w") as archive: + for name,value in data.items():archive.writestr(name,value) + archive.writestr(PREFIX+"worker-build.json",json.dumps(record)) + return manifest.inspect_package(package,"FS.GG.Wasm.Browser","0.5.0",SOURCE,"2"*40) + +class WorkerCustody(unittest.TestCase): + def test_actual_reader_joins_all_selected_byte_roles(self): + data,record=fixture();self.assertEqual(inspect(data,record)["id"],"FS.GG.Wasm.Browser") + + def test_changed_missing_and_renamed_bytes_refuse(self): + for target in [PREFIX+"module-worker.mjs",PREFIX+"policy/WorkerEntry.js","fable/js/module-worker.entry.mjs","build-provenance/worker-bundle/package-lock.json"]: + for mutation in ["changed","missing","renamed"]: + with self.subTest(target=target,mutation=mutation): + data,record=fixture();value=data.pop(target) + if mutation=="changed":data[target]=value+b"changed" + if mutation=="renamed":data[target+".other"]=value + with self.assertRaises(SystemExit):inspect(data,record) + + def test_incomplete_or_false_build_metadata_refuses(self): + for change in ["source","tree","tool","external","split","minify","nondeterministic","duplicate","escape","negative","boolean","missingpolicy"]: + with self.subTest(change=change): + data,record=fixture() + if change=="source":record["sourceCommit"]="3"*40 + if change=="tree":record["sourceTree"]="3"*40 + if change=="tool":record["tool"]["version"]="unselected" + if change=="external":record["composition"]["externalImports"]=1 + if change=="split":record["composition"]["outputFiles"]=2 + if change=="minify":record["composition"]["minify"]=True + if change=="nondeterministic":record["composition"]["deterministicBuildsCompared"]=1 + if change=="duplicate":record["generatedInputs"].append(copy.deepcopy(record["generatedInputs"][0])) + if change=="escape":record["generatedInputs"][0]["path"]="../escape.js" + if change=="negative":record["generatedInputs"][0]["bytes"]=-1 + if change=="boolean":record["generatedInputs"][0]["bytes"]=True + if change=="missingpolicy":record["generatedInputs"]=record["generatedInputs"][:-1] + with self.assertRaises(SystemExit):inspect(data,record) + +if __name__=="__main__":unittest.main() diff --git a/tests/release/wasm/test-worker-transport.mjs b/tests/release/wasm/test-worker-transport.mjs new file mode 100644 index 00000000..defc3290 --- /dev/null +++ b/tests/release/wasm/test-worker-transport.mjs @@ -0,0 +1,123 @@ +// Inert injected-mechanics contract. No Worker, browser, guest or real timer runs. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createHostTransport } from "../../../src/Wasm.Browser/js/worker-client.mjs"; + +function mechanics(run){ + const saved={Worker:globalThis.Worker,setTimeout:globalThis.setTimeout,clearTimeout:globalThis.clearTimeout}; + const workers=[],timers=new Map(),cleared=[];let nextTimer=0; + globalThis.Worker=class { + constructor(url,options){this.url=new URL(url);this.options=options;this.posts=[];this.terminations=0;workers.push(this);} + postMessage(value){this.posts.push(value);} + terminate(){this.terminations++;} + message(value){this.onmessage({data:value});} + }; + globalThis.setTimeout=(receive,delay)=>{const id=++nextTimer;timers.set(id,{receive,delay});return id;}; + globalThis.clearTimeout=id=>{cleared.push(id);timers.delete(id);}; + try{return run({workers,timers,cleared});}finally{Object.assign(globalThis,saved);} +} +const identity={WorkerInstance:"candidate",Request:"3",Generation:"2"}; +const command={Identity:identity,Correlation:"operation-3",Kind:"load",Bytes:new Uint8Array([91,92])}; +const advisory=(stage,elapsedMilliseconds=1)=>({kind:"fsgg-worker-diagnostic/1",stage,status:"observed",elapsedMilliseconds, + identity:stage==="handler-ready"?null:{...identity},correlation:stage==="handler-ready"?null:command.Correlation}); + +test("disabled diagnostics preserve actual transport arguments, timer and delivery",()=>mechanics(({workers,timers})=>{ + const received=[],t=createHostTransport("https://example.invalid/nested/assets/"); + t.CreateWorker("candidate",value=>received.push(value));t.PostCommand("candidate",command); + assert.equal(workers[0].url.href,"https://example.invalid/nested/assets/module-worker.mjs"); + assert.equal(workers[0].posts[0],command);assert.equal(typeof t.Now(),"bigint"); + let fired=0;t.ArmTimer("actual-key",7,()=>fired++); + assert.equal([...timers.values()][0].delay,7);[...timers.values()][0].receive();assert.equal(fired,1); + workers[0].message(advisory("handler-ready"));assert.deepEqual(received,[]); + const loaded={Kind:"loaded",Identity:identity,Correlation:command.Correlation};workers[0].message(loaded); + assert.equal(received[0],loaded);t.TerminateWorker("candidate");assert.equal(workers[0].terminations,1); + assert.equal(t.DiagnosticStatus().state,"disabled"); +})); + +test("correlated diagnostics cannot deliver or rearm and never copy guest payload",()=>mechanics(({workers,timers})=>{ + const received=[],rows=[],t=createHostTransport("https://example.invalid/sub/app/",{observe:row=>rows.push(row)}); + t.CreateWorker("candidate",value=>received.push(value));t.PostCommand("candidate",command); + t.ArmTimer("original-key",251,()=>{}); + assert.equal(workers[0].url.hash,"#fsgg-worker-diagnostics-v1"); + workers[0].message(advisory("handler-ready",0));workers[0].message(advisory("load-received",1)); + workers[0].message(advisory("admission-begin",2));workers[0].message(advisory("admission-end",3)); + assert.deepEqual(received,[]);assert.equal(timers.size,1);assert.equal([...timers.values()][0].delay,251); + assert.deepEqual(rows.filter(row=>row.stage==="worker-stage").map(row=>row.workerStage), + ["handler-ready","load-received","admission-begin","admission-end"]); + assert.equal(rows.some(row=>"Bytes"in row||"Output"in row||"Diagnostic"in row),false); + assert.equal(JSON.stringify(rows).includes("91,92"),false);assert.equal(t.DiagnosticStatus().readFailed,false); +})); + +test("throwing observer and unreadable diagnostic scalars cannot replace native effects",()=>mechanics(({workers,timers})=>{ + const t=createHostTransport("https://example.invalid/",{observe(){throw new Error("observer only");}}); + assert.doesNotThrow(()=>t.CreateWorker("candidate",()=>{})); + assert.doesNotThrow(()=>t.PostCommand("candidate",{Kind:"load"})); + assert.doesNotThrow(()=>t.ArmTimer("key",11,()=>{}));assert.equal(timers.size,1); + assert.equal(workers[0].posts.length,1);assert.equal(t.DiagnosticStatus().readFailed,true); + const other=createHostTransport("https://example.invalid/",{observe(){}}); + assert.doesNotThrow(()=>other.ArmTimer({},13,()=>{}));assert.equal(timers.size,2); + assert.equal(other.DiagnosticStatus().readFailed,true); +})); + +for(const mutation of ["foreign","backward","duplicate","unknown"]) +test(`malformed/foreign advisory is a gap, not a lifecycle receipt: ${mutation}`,()=>mechanics(({workers})=>{ + const received=[],t=createHostTransport("https://example.invalid/",{observe(){}}); + t.CreateWorker("candidate",value=>received.push(value));t.PostCommand("candidate",command); + workers[0].message(advisory("handler-ready",2)); + const row=advisory("admission-begin",3); + if(mutation==="foreign")row.identity.Request="99"; + if(mutation==="backward")row.elapsedMilliseconds=1; + if(mutation==="duplicate")Object.assign(row,advisory("handler-ready",3)); + if(mutation==="unknown")row.stage="invented"; + workers[0].message(row);assert.deepEqual(received,[]);assert.equal(t.DiagnosticStatus().readFailed,true); +})); + +test("late diagnostic cannot become readiness for a new same-name physical Worker",()=>mechanics(({workers})=>{ + const rows=[],t=createHostTransport("https://example.invalid/",{observe:row=>rows.push(row)}); + t.CreateWorker("candidate",()=>{});const old=workers[0];t.TerminateWorker("candidate"); + t.CreateWorker("candidate",()=>{});const count=rows.length;old.message(advisory("handler-ready",0)); + assert.equal(rows.length,count);assert.equal(t.DiagnosticStatus().incomplete,true); + assert.equal(t.DiagnosticStatus().unavailableWorkers,1); +})); + +test("late error cannot borrow a new same-name Worker's request identity",()=>mechanics(({workers})=>{ + const oldResults=[],newResults=[],t=createHostTransport("https://example.invalid/"); + t.CreateWorker("candidate",value=>oldResults.push(value));t.PostCommand("candidate",command); + const old=workers[0];t.TerminateWorker("candidate"); + t.CreateWorker("candidate",value=>newResults.push(value)); + const next={...command,Identity:{...identity,Request:"4"},Correlation:"operation-4"};t.PostCommand("candidate",next); + let prevented=0;old.onerror({preventDefault(){prevented++;},message:"retired physical error"}); + assert.equal(prevented,1);assert.deepEqual(oldResults,[]);assert.deepEqual(newResults,[]); + workers[1].onerror({preventDefault(){},message:"current physical error"}); + assert.equal(newResults.length,1);assert.equal(newResults[0].Identity,next.Identity); + assert.equal(newResults[0].Correlation,next.Correlation); +})); + +test("diagnostic count/bytes stop honestly without changing the returned clock",()=>mechanics(()=>{ + const rows=[],t=createHostTransport("https://example.invalid/",{observe:row=>rows.push(row)}); + for(let index=0;index<300;index++)assert.equal(typeof t.Now(),"bigint"); + const status=t.DiagnosticStatus();assert.equal(status.truncated,true);assert.equal(status.incomplete,true); + assert.ok(rows.length<=128);assert.ok(status.bytes<=24576); +})); + +test("an explicit observation scope excludes old traffic without resetting any clock or timer",()=>mechanics(({workers,timers})=>{ + const rows=[];let selected=false; + const t=createHostTransport("https://example.invalid/",{observe:row=>rows.push(row),include:()=>selected}); + t.CreateWorker("candidate",()=>{});t.PostCommand("candidate",command);t.ArmTimer("unrelated-active-key",251,()=>{}); + workers[0].message(advisory("handler-ready",0)); + for(let index=0;index<200;index++)t.Now(); + workers[0].message({kind:"fsgg-worker-diagnostic/1",stage:"status",status:"truncated",elapsedMilliseconds:1}); + assert.equal(t.DiagnosticStatus().truncated,false);assert.equal(rows.length,0); + t.TerminateWorker("candidate");selected=true; + t.CreateWorker("candidate",()=>{});t.PostCommand("candidate",command); + workers[1].message(advisory("handler-ready",0));workers[1].message(advisory("admission-begin",1)); + assert.equal(timers.size,1);assert.equal([...timers.values()][0].delay,251); + assert.equal(t.DiagnosticStatus().available,true);assert.ok(t.DiagnosticStatus().excludedEvents>=200); + assert.deepEqual(rows.filter(row=>row.stage==="worker-stage").map(row=>row.workerStage),["handler-ready","admission-begin"]); +})); + +test("unreadable diagnostic scope is a gap without blocking native creation",()=>mechanics(({workers})=>{ + const t=createHostTransport("https://example.invalid/",{observe(){},include(){return undefined;}}); + assert.doesNotThrow(()=>t.CreateWorker("candidate",()=>{}));assert.equal(workers.length,1); + assert.equal(t.DiagnosticStatus().state,"read-failed"); +}));